Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e80708677e | ||
|
|
12e3020c97 | ||
|
|
b04c4b37f4 | ||
|
|
798b277748 | ||
|
|
8a75294be2 | ||
|
|
43370de3ac | ||
|
|
f6a59de85b | ||
|
|
6fcc238c52 | ||
|
|
53a509553d | ||
|
|
1086e54ecc | ||
|
|
f4cb486243 | ||
|
|
8c600087dc | ||
|
|
1b0b072c4b | ||
|
|
cf337851ff | ||
|
|
767634aea5 | ||
|
|
e7fa061e23 | ||
|
|
d114a5d40c | ||
|
|
c6db9d41d6 | ||
|
|
332e46bb9e | ||
|
|
1a07475cfc | ||
|
|
60d162c259 | ||
|
|
ae1602ad7a | ||
|
|
1a3164bf16 | ||
|
|
8bc564b4b2 | ||
|
|
dfc14bcd1f | ||
|
|
7a1e12bc7c | ||
|
|
c6c07cc3a3 | ||
|
|
a306d6b2a8 | ||
|
|
b2d713e774 | ||
|
|
1bf8ebca96 | ||
|
|
dfd2e41259 | ||
|
|
05a84b217d | ||
|
|
e7e37d485d | ||
|
|
b35dede75f | ||
|
|
5fa9c905c8 | ||
|
|
7a666076bf | ||
|
|
f5467e50c5 | ||
|
|
74989b6f25 | ||
|
|
524dabb9c3 | ||
|
|
d1d8f0bbed | ||
|
|
0edadeebdb | ||
|
|
93e3137342 |
Generated
+6
@@ -2155,10 +2155,16 @@ dependencies = [
|
||||
name = "oa-desktop"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"iced",
|
||||
"iced_runtime",
|
||||
"oa-core",
|
||||
"oa-server",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ The normative [language and editing contract](docs/language.rst) defines support
|
||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
@@ -102,7 +102,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
||||
|
||||
## Save and reopen
|
||||
|
||||
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
|
||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
|
||||
|
||||
@@ -118,14 +118,18 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
|
||||
|
||||
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
|
||||
|
||||
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
|
||||
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
|
||||
|
||||
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
|
||||
|
||||
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
|
||||
|
||||
```sh
|
||||
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
||||
-H 'Content-Type: application/json' --data-binary @system.json
|
||||
```
|
||||
|
||||
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
|
||||
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
|
||||
|
||||
@@ -142,4 +146,4 @@ cargo build --release --workspace --locked
|
||||
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
|
||||
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
|
||||
|
||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
|
||||
use super::historical::keyed_slots_overlap;
|
||||
use super::historical::{keyed_slots_overlap, selector_slots};
|
||||
use super::mutate::*;
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
|
||||
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
||||
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
||||
}
|
||||
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
|
||||
match selector {
|
||||
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
|
||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
||||
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
|
||||
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
|
||||
match layer {
|
||||
Layer::Oa => &review.oa,
|
||||
Layer::Sa => &review.sa,
|
||||
}
|
||||
FacetSelector::Literal(keys) => {
|
||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
||||
}
|
||||
match fact {
|
||||
FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } => {
|
||||
matches!(observed(review, layer), Observed::Value(true))
|
||||
}
|
||||
FacetSelector::Annotation(keys) => {
|
||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
||||
FacetReview::Annotation { review, .. } => {
|
||||
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||
}
|
||||
FacetReview::Binding { review, .. } => {
|
||||
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
|
||||
source_layer: Layer,
|
||||
target_layer: Layer,
|
||||
) -> Result<(), String> {
|
||||
// A correspondence key can exist without membership on that side (for
|
||||
// example an OA literal automatically projects the same SA literal key).
|
||||
// Preserve actual target-only values, except previously reviewed removals.
|
||||
for fact in initial_facts {
|
||||
if !constructor_contains(group, &fact.selector)
|
||||
|| !target_only(&fact.selector, source_layer, target_layer)
|
||||
|| member_present(&fact.current, source_layer)
|
||||
|| !member_present(&fact.current, target_layer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let slot = target_slot(target, &fact.selector, target_layer)?;
|
||||
let was_corresponding = baseline.facets.iter().any(|old| {
|
||||
!target_only(&old.selector(), source_layer, target_layer)
|
||||
member_present(old, source_layer)
|
||||
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
|
||||
});
|
||||
if was_corresponding {
|
||||
@@ -723,12 +733,28 @@ fn build_review(
|
||||
constructor_actions.push((target, selected.clone(), local_record));
|
||||
}
|
||||
let refreshed = semantic_facets(observed, &trace, &references)?;
|
||||
let group_selectors: BTreeSet<_> = refreshed
|
||||
let mut group_selectors: BTreeSet<_> = refreshed
|
||||
.iter()
|
||||
.chain(all.iter())
|
||||
.map(|f| f.selector.clone())
|
||||
.filter(|f| constructor_contains(selected, f))
|
||||
.collect();
|
||||
// A constructor copy can turn a live one-sided member into a
|
||||
// pair. Its initial projection is not another reviewed fact.
|
||||
// Established one-sided history still requires an explicit reset.
|
||||
let paired_sides: BTreeSet<_> = group_selectors
|
||||
.iter()
|
||||
.map(selector_slots)
|
||||
.filter(|slots| slots.len() == 2)
|
||||
.flatten()
|
||||
.collect();
|
||||
group_selectors.retain(|selector| {
|
||||
!paired_sides.contains(selector)
|
||||
|| baseline
|
||||
.facets
|
||||
.iter()
|
||||
.any(|old| old.selector() == *selector)
|
||||
});
|
||||
let mut group = vec![];
|
||||
for selector in group_selectors {
|
||||
match capture_facet(observed, oa, sa, &selector) {
|
||||
|
||||
@@ -395,11 +395,30 @@ pub fn plan_transition(
|
||||
commands: vec![],
|
||||
};
|
||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||
let omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
let mut omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
sa_port_omissions(project)
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
// An explicit existing Legacy counterpart can be compared and reviewed
|
||||
// without inventing an OA typed port. Only this port is retained: dependent
|
||||
// SA implementation records and new counterpart requests stay omitted.
|
||||
for mapping in &request.mappings {
|
||||
if project
|
||||
.elements
|
||||
.get(&mapping.source)
|
||||
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
|
||||
&& mapping
|
||||
.target
|
||||
.and_then(|target| project.elements.get(&target))
|
||||
.is_some_and(|e| {
|
||||
e.layer() == Layer::Oa
|
||||
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
|
||||
})
|
||||
{
|
||||
omitted.remove(&mapping.source);
|
||||
}
|
||||
}
|
||||
let mut reported_omissions = BTreeSet::new();
|
||||
if selected.len() != request.sources.len() {
|
||||
conflict(
|
||||
|
||||
@@ -1917,3 +1917,395 @@ fn retired_source_branch_retains_historical_member_pair_in_both_directions() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_closure_preserves_target_only_literals_in_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
|
||||
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
enumeration(&mut p, target, &["local"]);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
|
||||
p.traces
|
||||
.get_mut(&t)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: facts.into_iter().map(|f| f.current).collect(),
|
||||
});
|
||||
enumeration(&mut p, source, &["incoming"]);
|
||||
let mut r = request(
|
||||
source,
|
||||
target,
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
SemanticDecision::TakeSource,
|
||||
)],
|
||||
);
|
||||
if reverse {
|
||||
r.transition.direction = TransitionDirection::SaToOa;
|
||||
}
|
||||
let q = applied(&p, r);
|
||||
assert!(
|
||||
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals }, ..
|
||||
}) if literals == &["incoming", "local"]),
|
||||
"reverse={reverse}"
|
||||
);
|
||||
let base = q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
validate_semantic_baseline(base).unwrap();
|
||||
let incoming = FacetSelector::Literal(KeyPair {
|
||||
oa: Some("incoming".into()),
|
||||
sa: Some("incoming".into()),
|
||||
});
|
||||
assert_eq!(
|
||||
base.facets
|
||||
.iter()
|
||||
.filter(|f| f.selector() == incoming)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert!(
|
||||
facts
|
||||
.iter()
|
||||
.filter(|f| matches!(f.selector, FacetSelector::Literal(_)))
|
||||
.all(|f| f.drift == Drift::Unchanged)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_closure_pairs_signal_members_and_requires_history_reset_in_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
for historical in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Event, Layer::Oa, "oa");
|
||||
let sa = add(&mut p, Kind::Event, Layer::Sa, "sa");
|
||||
let oa_item = add(&mut p, Kind::ExchangeItem, Layer::Oa, "oa_item");
|
||||
let sa_item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "sa_item");
|
||||
let (source, target, source_item) = if reverse {
|
||||
(sa, oa, sa_item)
|
||||
} else {
|
||||
(oa, sa, oa_item)
|
||||
};
|
||||
set(
|
||||
&mut p,
|
||||
source,
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal {
|
||||
items: vec![source_item],
|
||||
},
|
||||
},
|
||||
);
|
||||
set(
|
||||
&mut p,
|
||||
target,
|
||||
SharedBody::Event {
|
||||
body: EventBody::Change {
|
||||
expression: "old".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
let previous = FacetSelector::Member {
|
||||
collection: ReferenceCollection::SignalItems,
|
||||
keys: if reverse {
|
||||
KeyPair {
|
||||
oa: None,
|
||||
sa: Some(sa_item),
|
||||
}
|
||||
} else {
|
||||
KeyPair {
|
||||
oa: Some(oa_item),
|
||||
sa: None,
|
||||
}
|
||||
},
|
||||
};
|
||||
if historical {
|
||||
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
|
||||
let old = facts
|
||||
.into_iter()
|
||||
.find(|f| f.selector == previous)
|
||||
.unwrap()
|
||||
.current;
|
||||
p.traces
|
||||
.get_mut(&t)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline { facets: vec![old] });
|
||||
}
|
||||
let mut r = request(
|
||||
source,
|
||||
target,
|
||||
vec![(
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
SemanticDecision::TakeSource,
|
||||
)],
|
||||
);
|
||||
if reverse {
|
||||
r.transition.direction = TransitionDirection::SaToOa;
|
||||
}
|
||||
r.references.insert(oa_item, sa_item);
|
||||
if historical {
|
||||
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
|
||||
assert!(
|
||||
!invalid.conflicts.is_empty(),
|
||||
"Established one-sided history requires reset: reverse={reverse}"
|
||||
);
|
||||
r.reviews[0].reset.push(previous);
|
||||
}
|
||||
let q = applied(&p, r);
|
||||
let target_item = if reverse { oa_item } else { sa_item };
|
||||
assert!(
|
||||
matches!(q.elements[&target].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if items == &[target_item])
|
||||
);
|
||||
let base = q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
validate_semantic_baseline(base).unwrap();
|
||||
let members: Vec<_> = base
|
||||
.facets
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
matches!(
|
||||
f.selector(),
|
||||
FacetSelector::Member {
|
||||
collection: ReferenceCollection::SignalItems,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
members.len(),
|
||||
1,
|
||||
"reverse={reverse}, historical={historical}"
|
||||
);
|
||||
assert_eq!(
|
||||
members[0].selector(),
|
||||
FacetSelector::Member {
|
||||
collection: ReferenceCollection::SignalItems,
|
||||
keys: KeyPair {
|
||||
oa: Some(oa_item),
|
||||
sa: Some(sa_item)
|
||||
}
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&q)
|
||||
.iter()
|
||||
.all(|issue| issue.severity != validation::Severity::Error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_closure_does_not_restore_previously_corresponding_removed_members() {
|
||||
for reverse in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
|
||||
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
enumeration(&mut p, source, &["paired"]);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
let constructor = FacetSelector::Data(DataFacet::Constructor);
|
||||
let directed = |facets| {
|
||||
let mut r = request(source, target, facets);
|
||||
if reverse {
|
||||
r.transition.direction = TransitionDirection::SaToOa;
|
||||
}
|
||||
r
|
||||
};
|
||||
p = applied(
|
||||
&p,
|
||||
directed(vec![(constructor.clone(), SemanticDecision::KeepTarget)]),
|
||||
);
|
||||
if reverse {
|
||||
// Explicitly establish the counterpart key while its target branch
|
||||
// is absent, rather than silently changing the one-sided history.
|
||||
let mut r = directed(vec![(
|
||||
FacetSelector::Literal(KeyPair {
|
||||
oa: Some("paired".into()),
|
||||
sa: Some("paired".into()),
|
||||
}),
|
||||
SemanticDecision::KeepTarget,
|
||||
)]);
|
||||
r.reviews[0].reset.push(FacetSelector::Literal(KeyPair {
|
||||
oa: None,
|
||||
sa: Some("paired".into()),
|
||||
}));
|
||||
p = applied(&p, r);
|
||||
}
|
||||
enumeration(&mut p, source, &["incoming"]);
|
||||
enumeration(&mut p, target, &["paired", "local"]);
|
||||
let q = applied(
|
||||
&p,
|
||||
directed(vec![(constructor, SemanticDecision::TakeSource)]),
|
||||
);
|
||||
assert!(
|
||||
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals }, ..
|
||||
}) if literals == &["incoming", "local"]),
|
||||
"reverse={reverse}"
|
||||
);
|
||||
validate_semantic_baseline(
|
||||
q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse_legacy_port_fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa_owner = add(&mut p, Kind::Actor, Layer::Oa, "oa owner");
|
||||
let sa_owner = add(&mut p, Kind::SystemActor, Layer::Sa, "sa owner");
|
||||
let oa = Id::new_v4();
|
||||
let sa = Id::new_v4();
|
||||
let mut legacy = make_port(&p, oa, oa_owner, "oa port", PortContract::Legacy).unwrap();
|
||||
*legacy.shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
};
|
||||
p.elements.insert(oa, legacy);
|
||||
let typed = make_port(
|
||||
&p,
|
||||
sa,
|
||||
sa_owner,
|
||||
"sa port",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(sa, typed);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
(p, oa, sa, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_existing_port_review_retains_legacy_and_roundtrips_typed_history() {
|
||||
for decision in [SemanticDecision::KeepTarget, SemanticDecision::Candidate] {
|
||||
let (p, oa, sa, t) = reverse_legacy_port_fixture();
|
||||
let mut r = request(
|
||||
sa,
|
||||
oa,
|
||||
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
|
||||
);
|
||||
r.transition.direction = TransitionDirection::SaToOa;
|
||||
r.traces.insert(sa, t);
|
||||
let expected_direction = if decision == SemanticDecision::Candidate {
|
||||
let mut candidate = p.elements[&oa].clone();
|
||||
let Some(SharedBody::Port {
|
||||
legacy: Some(old), ..
|
||||
}) = candidate.shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
old.direction = PortDirection::In;
|
||||
r.candidates.push(SemanticRecord::Element(candidate));
|
||||
PortDirection::In
|
||||
} else {
|
||||
PortDirection::InOut
|
||||
};
|
||||
let q = applied(&p, r);
|
||||
assert_eq!(q.elements[&sa], p.elements[&sa]);
|
||||
assert_eq!(q.elements.len(), p.elements.len());
|
||||
assert!(matches!(q.elements[&oa].shared(), Some(SharedBody::Port {
|
||||
contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, interface: None }),
|
||||
}) if *direction == expected_direction));
|
||||
let baseline = q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
validate_semantic_baseline(baseline).unwrap();
|
||||
assert!(baseline.facets.iter().any(|f| matches!(
|
||||
f,
|
||||
FacetReview::Port(PortReview::Constructor(Reviewed {
|
||||
oa: Observed::Value(PortConstructor::Legacy),
|
||||
sa: Observed::Value(PortConstructor::Component),
|
||||
}))
|
||||
)));
|
||||
assert_eq!(language::parse(&language::format(&q)).unwrap(), q);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&q).unwrap()).unwrap(),
|
||||
q
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_existing_port_review_rejects_typed_oa_proposals_without_mutation() {
|
||||
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
|
||||
let (p, oa, sa, _) = reverse_legacy_port_fixture();
|
||||
let snapshot = p.clone();
|
||||
let mut r = request(
|
||||
sa,
|
||||
oa,
|
||||
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
|
||||
);
|
||||
r.transition.direction = TransitionDirection::SaToOa;
|
||||
if decision == SemanticDecision::Candidate {
|
||||
let mut candidate = p.elements[&oa].clone();
|
||||
*candidate.shared_mut().unwrap() = p.elements[&sa].shared().unwrap().clone();
|
||||
r.candidates.push(SemanticRecord::Element(candidate));
|
||||
}
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
assert!(!plan.conflicts.is_empty());
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.all(|c| !c.contains("outside selected transition")),
|
||||
"Must reach structural validation: {:?}",
|
||||
plan.conflicts
|
||||
);
|
||||
assert!(prepare_semantic_transition(&p, &plan).is_err());
|
||||
assert_eq!(p, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_new_port_counterpart_remains_an_explicit_sa_only_omission() {
|
||||
let (p, _, sa, _) = reverse_legacy_port_fixture();
|
||||
let mut r = TransitionRequest::new(TransitionDirection::SaToOa, vec![sa]);
|
||||
r.mappings.push(TransitionMapping {
|
||||
source: sa,
|
||||
target: None,
|
||||
target_kind: Some(Kind::Port),
|
||||
});
|
||||
let plan = plan_transition(&p, &r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert!(plan.changes.is_empty());
|
||||
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
|
||||
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
|
||||
}
|
||||
|
||||
@@ -13,5 +13,14 @@ path = "src/main.rs"
|
||||
oa-core = { path = "../oa-core" }
|
||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
||||
rfd = "0.15"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
oa-server = { path = "../oa-server" }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod hosted;
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
document::Document,
|
||||
@@ -12,8 +13,219 @@ use oa_core::{
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
|
||||
|
||||
#[cfg(test)]
|
||||
mod direct_scenario_tests {
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
|
||||
};
|
||||
fn setup() -> (App, scenario::direct_tests::Fixture) {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
|
||||
(app, f)
|
||||
}
|
||||
fn form(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
|
||||
}
|
||||
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
|
||||
CanvasEvent::Begin {
|
||||
context: app.scenario_context().unwrap(),
|
||||
slot: Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
},
|
||||
sender: f.sender,
|
||||
receiver: f.receiver,
|
||||
style: MessageStyle::Asynchronous,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
assert_eq!(app.scenario_context(), Some(context));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||
size.width + 20.,
|
||||
size.height,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(context));
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
let context = app.scenario_context().unwrap();
|
||||
for (area, offset) in [
|
||||
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||||
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||||
] {
|
||||
let before = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_ne!(app.scenario_context(), Some(before));
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(current),
|
||||
"unchanged redraw notification retains context"
|
||||
);
|
||||
}
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
form(&mut app, Event::Name("Keep my draft".into()));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 200.),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
let current = app.scenario_context().unwrap();
|
||||
for dimension in 0..3 {
|
||||
let mut stale = current;
|
||||
match dimension {
|
||||
0 => stale.document = Id::new_v4(),
|
||||
1 => stale.scenario = Id::new_v4(),
|
||||
_ => stale.revision += 1,
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
}
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
|
||||
let (mut app, f) = setup();
|
||||
let before = app.document.snapshot();
|
||||
for dimension in 0..4 {
|
||||
let mut event = begin(&app, &f);
|
||||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||
unreachable!()
|
||||
};
|
||||
match dimension {
|
||||
0 => context.document = Id::new_v4(),
|
||||
1 => context.scenario = Id::new_v4(),
|
||||
2 => context.revision += 1,
|
||||
_ => context.epoch += 1,
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending scenario name".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.bundle().clone();
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Exchange(Choice {
|
||||
id: f.exchange,
|
||||
label: "Contract".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
let created = app.selected;
|
||||
assert_ne!(created, f.owner);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[f.call, f.reply, created]
|
||||
);
|
||||
let before_reorder = app.document.bundle().clone();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, f.sender,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(context),
|
||||
"same-owner selection retains gesture context"
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: created,
|
||||
slot: Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
},
|
||||
}));
|
||||
assert_eq!(app.selected, created);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[created, f.call, f.reply]
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before_reorder);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert_eq!(app.project(), &f.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
|
||||
let (mut app, f) = setup();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes =
|
||||
bundle.files.values().map(String::len).sum::<usize>() + 4000;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
app.sync();
|
||||
let event = begin(&app, &f);
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Exchange(Choice {
|
||||
id: f.exchange,
|
||||
label: "Contract".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Long message".repeat(1024)));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
form(&mut app, Event::Name("Recovered message".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.project().named(app.selected), "Recovered message");
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod behavior_lifecycle_tests;
|
||||
mod process_canvas;
|
||||
mod state_diagram;
|
||||
#[cfg(test)]
|
||||
mod state_diagram_tests;
|
||||
@@ -69,6 +281,7 @@ impl std::fmt::Display for Choice {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Pending {
|
||||
HostedOpen,
|
||||
New,
|
||||
Demo,
|
||||
Open,
|
||||
@@ -88,6 +301,7 @@ pub struct FilePrompt {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Hosted(crate::hosted::Event),
|
||||
PortAnchor(crate::port_edit::Event),
|
||||
Connection(crate::connection_edit::Event),
|
||||
Menu(crate::model_menu::Event),
|
||||
@@ -131,6 +345,7 @@ pub enum Message {
|
||||
FieldShape(String, Value),
|
||||
FieldAppend(String, Value),
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -148,6 +363,8 @@ pub enum Message {
|
||||
BehaviorOwner(Choice),
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
ToggleCollapse,
|
||||
@@ -196,6 +413,7 @@ pub enum Message {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub hosted: crate::hosted::State,
|
||||
pub connection: Option<crate::connection_edit::Session>,
|
||||
pub model_menu: Option<crate::model_menu::Opened>,
|
||||
pub source_tree: crate::source_tree::State,
|
||||
@@ -311,6 +529,7 @@ impl App {
|
||||
Kind::Activity
|
||||
},
|
||||
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
||||
hosted: Default::default(),
|
||||
busy: false,
|
||||
pending: None,
|
||||
delete_pending: false,
|
||||
@@ -580,9 +799,14 @@ impl App {
|
||||
}
|
||||
fn execute(&mut self, command: Command) -> bool {
|
||||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(id) = selected {
|
||||
if let Some(id) = scenario_selection.or(selected) {
|
||||
self.selected = id;
|
||||
}
|
||||
self.sync();
|
||||
@@ -625,7 +849,10 @@ impl App {
|
||||
let source_version = self.source_version + 1;
|
||||
let lint_running = self.lint_running;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
hosted.leave_document();
|
||||
*self = Self::with_project(project);
|
||||
self.hosted = hosted;
|
||||
self.window_size = window_size;
|
||||
self.canvas_size = canvas_size;
|
||||
self.source_version = source_version;
|
||||
@@ -687,8 +914,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
||||
.into()
|
||||
let base = crate::context_menu::Popup::new(
|
||||
crate::views::view(self),
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, base)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -738,6 +969,19 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Scrollable geometry can change while async work or an overlay blocks
|
||||
// commands. Keep the observed translation current; its passive handler
|
||||
// still validates document, scenario and revision before accepting it.
|
||||
if matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
|
||||
| Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| crate::behavior::topology::CanvasEvent::Viewport(..)
|
||||
)
|
||||
) {
|
||||
return self.handle(message);
|
||||
}
|
||||
// Global subscriptions also receive keys consumed by an overlay.
|
||||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||||
self.model_menu = None;
|
||||
@@ -787,6 +1031,7 @@ impl App {
|
||||
&& !matches!(
|
||||
message,
|
||||
Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(_, _)
|
||||
| Message::SavePath(_)
|
||||
@@ -806,6 +1051,10 @@ impl App {
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
if self.hosted.visible {
|
||||
self.hosted.visible = false;
|
||||
return Task::none();
|
||||
}
|
||||
if self.workflow.take().is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -830,6 +1079,7 @@ impl App {
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(_, _)
|
||||
| Message::SavePath(_)
|
||||
@@ -837,7 +1087,8 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
);
|
||||
if !modal_event
|
||||
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|
||||
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||
|| (self.file_prompt.is_some()
|
||||
&& !matches!(
|
||||
message,
|
||||
@@ -895,6 +1146,11 @@ impl App {
|
||||
let entering_catalog =
|
||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||
let mut task = self.handle(message);
|
||||
if let Some(context) = self.topology_context() {
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
}
|
||||
if self.connection.as_ref().is_some_and(|session| {
|
||||
Some(session.context) != self.route_context()
|
||||
|| self.page != Page::Diagram
|
||||
@@ -937,8 +1193,117 @@ impl App {
|
||||
task
|
||||
}
|
||||
}
|
||||
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
|
||||
let scenario = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
|
||||
.then_some(crate::behavior::scenario::Context {
|
||||
document: self.canvas_document,
|
||||
scenario,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Hosted(event) => return self.hosted_event(event),
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
// Inner and outer scroll notifications may come from the
|
||||
// same frame. Accept both across the first epoch bump,
|
||||
// while still rejecting another owner/document/revision.
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.behavior.scenario.scrolled(area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id)
|
||||
== Some(context.scenario)
|
||||
{
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.scenario.choose_tool(context.scenario, tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
{
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the scenario."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::Begin {
|
||||
slot,
|
||||
sender,
|
||||
receiver,
|
||||
style,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_message(
|
||||
self.project(),
|
||||
context,
|
||||
slot,
|
||||
sender,
|
||||
receiver,
|
||||
style,
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reorder {
|
||||
occurrence, slot, ..
|
||||
} => {
|
||||
match scenario::reorder(
|
||||
self.project(),
|
||||
context.scenario,
|
||||
occurrence,
|
||||
slot,
|
||||
) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
|
||||
Message::StateDiagram(event) => return self.edit_state_diagram(event),
|
||||
Message::BehaviorOwner(choice) => {
|
||||
if self.apply()
|
||||
@@ -1140,6 +1505,18 @@ impl App {
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
Message::FieldValueBrowse(path, scope, page) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& draft
|
||||
.value
|
||||
.pointer(&path)
|
||||
.is_some_and(|row| row.get("expression").is_some())
|
||||
{
|
||||
draft
|
||||
.value_browsers
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldValuePath(path, key) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.value_path(&path, key)
|
||||
@@ -1559,6 +1936,9 @@ impl App {
|
||||
Ok(Some(command)) => {
|
||||
if self.execute(command) {
|
||||
self.behavior.acknowledge_commit();
|
||||
if self.topology_context().is_some() {
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -2130,6 +2510,9 @@ impl App {
|
||||
return self.handle(Message::Save(false));
|
||||
}
|
||||
Message::Save(save_as) => {
|
||||
if !save_as && self.hosted.session.is_some() {
|
||||
return self.save_hosted();
|
||||
}
|
||||
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
@@ -2181,6 +2564,9 @@ impl App {
|
||||
}
|
||||
self.disk_project = Some(disk);
|
||||
self.path = Some(receipt.path);
|
||||
self.hosted.session = None;
|
||||
self.hosted.review = None;
|
||||
self.hosted.uncertain = None;
|
||||
self.document.mark_saved(receipt.snapshot);
|
||||
self.prune_closed_files(!self.document.dirty());
|
||||
self.status = "All source files saved".into();
|
||||
@@ -2284,7 +2670,16 @@ impl App {
|
||||
Err(e) => e,
|
||||
};
|
||||
}
|
||||
Message::WindowResized(size) => self.window_size = size,
|
||||
Message::WindowResized(size) => {
|
||||
// The canvas can retain its capped size during a real window
|
||||
// resize. Invalidate any sequence pointer/slot gesture anyway.
|
||||
if self.window_size != size
|
||||
&& (self.scenario_context().is_some() || self.topology_context().is_some())
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.window_size = size;
|
||||
}
|
||||
Message::CanvasResized(size) => {
|
||||
let first_measurement = self.canvas_size.is_none();
|
||||
let size_changed = self.canvas_size != Some(size);
|
||||
@@ -2510,6 +2905,7 @@ impl App {
|
||||
fn proceed(&mut self, pending: Pending) -> Task<Message> {
|
||||
self.pending = None;
|
||||
match pending {
|
||||
Pending::HostedOpen => self.open_hosted(),
|
||||
Pending::New => {
|
||||
self.reset(Project::empty(), None);
|
||||
Task::none()
|
||||
@@ -2658,10 +3054,20 @@ impl App {
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.status =
|
||||
"Source project opened. Included files and comments are preserved.".into();
|
||||
self.migration = self.document.compiled().decoded.migration.clone();
|
||||
if self.migration.is_some() {
|
||||
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
|
||||
self.page = Page::Validation;
|
||||
} else {
|
||||
self.status =
|
||||
"Source project opened. Included files and comments are preserved.".into();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
// Installing a local repair workspace changes its destination,
|
||||
// even while the last valid graphical model stays visible. A
|
||||
// failed/cancelled Open that installs nothing retains its binding.
|
||||
self.hosted.leave_document();
|
||||
self.canvas_document = Id::new_v4();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
@@ -3289,7 +3695,9 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
|
||||
disk.expectations.get(&disk.bundle.entry)
|
||||
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
|
||||
&& decoded.migration.is_some()
|
||||
// Source1/2 require the historical single-document adapters.
|
||||
// Source3+ must retain its authored bundle and token migration.
|
||||
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
|
||||
{
|
||||
return Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
@@ -3374,6 +3782,97 @@ mod source_project_tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
|
||||
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
|
||||
for split in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let path = directory.0.join("project.arcadia");
|
||||
let mut authored = std::collections::BTreeMap::new();
|
||||
if split {
|
||||
let start = original.find("{\n").unwrap() + 2;
|
||||
let end = original.rfind('}').unwrap();
|
||||
authored.insert(
|
||||
"project.arcadia",
|
||||
format!(
|
||||
"{} include \"dictionary.arcadia\";\n}}\n",
|
||||
&original[..start]
|
||||
),
|
||||
);
|
||||
authored.insert("dictionary.arcadia", original[start..end].to_owned());
|
||||
} else {
|
||||
authored.insert("project.arcadia", original.to_owned());
|
||||
}
|
||||
for (file, source) in &authored {
|
||||
std::fs::write(directory.0.join(file), source).unwrap();
|
||||
}
|
||||
let mut app = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
);
|
||||
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert_eq!(app.page, Page::Validation);
|
||||
assert_eq!(app.path, Some(path.clone()));
|
||||
assert_eq!(app.document.bundle().files.len(), authored.len());
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"accepted source migration needs explicit saving"
|
||||
);
|
||||
for (file, source) in &authored {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||||
*source
|
||||
);
|
||||
}
|
||||
let text = app
|
||||
.document
|
||||
.bundle()
|
||||
.files
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<String>();
|
||||
assert!(text.contains("// Reused record parameters retain their own identities."));
|
||||
assert!(text.contains("// Frozen authored schema3 compatibility input."));
|
||||
let packet = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "packet")
|
||||
.unwrap()
|
||||
.id;
|
||||
let origin = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "origin")
|
||||
.unwrap()
|
||||
.id;
|
||||
assert_eq!(
|
||||
app.document.compiled().index.lexical_owners.get(&origin),
|
||||
Some(&packet)
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
save(&mut app, path.clone()).unwrap();
|
||||
assert!(!app.dirty());
|
||||
for (file, source) in &before.bundle.files {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||||
*source
|
||||
);
|
||||
}
|
||||
let mut reopened = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut reopened,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
|
||||
);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.document.bundle(), app.document.bundle());
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
|
||||
let path = directory.0.join("project.arcadia");
|
||||
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
|
||||
|
||||
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
|
||||
Message::Field("/name".into(), "Unapplied state name".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let context = behavior::topology::Context {
|
||||
document: app.canvas_document,
|
||||
owner: fixture.machine,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
};
|
||||
for event in [
|
||||
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
|
||||
900., 600.,
|
||||
))),
|
||||
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
|
||||
behavior::Event::Topology(behavior::topology::Event::Fit),
|
||||
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, None),
|
||||
] {
|
||||
send(app, Message::Behavior(event));
|
||||
send(app, Message::TopologyCanvas(event));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.behavior.has_pending());
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
|
||||
use super::*;
|
||||
use crate::hosted::{
|
||||
Event, Operation, Outcome, Request, Review, Session,
|
||||
client::{Connection, Failure, Remote},
|
||||
};
|
||||
|
||||
impl App {
|
||||
pub fn hosted_label(&self) -> String {
|
||||
self.hosted.session.as_ref().map_or_else(
|
||||
|| {
|
||||
if self.dirty() {
|
||||
"Unsaved changes".into()
|
||||
} else if self.path.is_some() {
|
||||
"All changes saved".into()
|
||||
} else {
|
||||
"Local project".into()
|
||||
}
|
||||
},
|
||||
|session| {
|
||||
format!(
|
||||
"Hosted · revision {} · {}",
|
||||
session.revision,
|
||||
if self.dirty() {
|
||||
"local changes"
|
||||
} else {
|
||||
"saved"
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn save_label(&self) -> &'static str {
|
||||
if self.hosted.session.is_some() {
|
||||
"Save to server"
|
||||
} else {
|
||||
"Save all"
|
||||
}
|
||||
}
|
||||
|
||||
fn hosted_problem(&mut self, detail: impl Into<String>) {
|
||||
let detail = detail.into();
|
||||
self.status = detail.clone();
|
||||
self.hosted.error = Some(Failure {
|
||||
detail,
|
||||
code: "desktop_hosted".into(),
|
||||
status: None,
|
||||
uncertain: false,
|
||||
published: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn hosted_ready(&mut self) -> bool {
|
||||
if self.pending_recovery.is_some() {
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
self.hosted.visible = false;
|
||||
self.page = Page::Text;
|
||||
self.status =
|
||||
"Recover the interrupted source save before changing storage destinations.".into();
|
||||
return false;
|
||||
}
|
||||
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
||||
self.hosted.visible = false;
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return false;
|
||||
}
|
||||
if self.source_workspace.detached_dirty() {
|
||||
self.hosted.visible = false;
|
||||
self.page = Page::Text;
|
||||
self.status =
|
||||
"Reattach or revert detached source drafts before saving to a server.".into();
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn hosted_start(
|
||||
&mut self,
|
||||
operation: Operation,
|
||||
connection: Connection,
|
||||
resource_id: Option<Id>,
|
||||
) -> Task<Message> {
|
||||
let request = Request {
|
||||
id: Id::new_v4(),
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
operation,
|
||||
connection,
|
||||
resource_id,
|
||||
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
|
||||
snapshot: self.document.snapshot(),
|
||||
};
|
||||
self.hosted.request = Some(request.clone());
|
||||
if operation != Operation::Check {
|
||||
self.hosted.error = None;
|
||||
}
|
||||
self.busy = true;
|
||||
self.status = match operation {
|
||||
Operation::Catalog(_) => "Reading hosted models…",
|
||||
Operation::Open(_) => "Opening hosted model…",
|
||||
Operation::Publish => "Publishing source project…",
|
||||
Operation::Save => "Saving to server…",
|
||||
Operation::Check => "Checking current server revision…",
|
||||
}
|
||||
.into();
|
||||
let query = self.hosted.query.clone();
|
||||
let after = if operation == Operation::Catalog(true) {
|
||||
self.hosted.next
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Task::perform(
|
||||
async move {
|
||||
let result = match operation {
|
||||
Operation::Catalog(_) => request
|
||||
.connection
|
||||
.catalog(&query, after)
|
||||
.await
|
||||
.map(Outcome::Catalog),
|
||||
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
|
||||
Operation::Publish => request
|
||||
.connection
|
||||
.publish(request.snapshot.bundle.clone())
|
||||
.await
|
||||
.map(Outcome::Remote),
|
||||
Operation::Save => request
|
||||
.connection
|
||||
.save(
|
||||
request.resource_id.expect("save has a resource"),
|
||||
request.etag.as_deref().expect("save has a precondition"),
|
||||
request.snapshot.bundle.clone(),
|
||||
)
|
||||
.await
|
||||
.map(Outcome::Remote),
|
||||
Operation::Check => request
|
||||
.connection
|
||||
.open(request.resource_id.expect("check has a resource"))
|
||||
.await
|
||||
.map(Outcome::Remote),
|
||||
};
|
||||
Message::Hosted(Event::Completed(request.id, request.document, result))
|
||||
},
|
||||
|message| message,
|
||||
)
|
||||
}
|
||||
pub(super) fn open_hosted(&mut self) -> Task<Message> {
|
||||
if self.pending_recovery.is_some() {
|
||||
self.hosted_problem(
|
||||
"Recover the interrupted source save before opening another model.",
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
if let Some((connection, id)) = self.hosted.open_target.take() {
|
||||
return self.hosted_start(Operation::Open(id), connection, Some(id));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub(super) fn save_hosted(&mut self) -> Task<Message> {
|
||||
if !self.hosted_ready() {
|
||||
return Task::none();
|
||||
}
|
||||
if self.hosted.review.is_some() {
|
||||
self.hosted.visible = true;
|
||||
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(session) = &self.hosted.session {
|
||||
return self.hosted_start(
|
||||
Operation::Save,
|
||||
session.connection.clone(),
|
||||
Some(session.resource_id),
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn check_hosted(&mut self) -> Task<Message> {
|
||||
let destination = self
|
||||
.hosted
|
||||
.uncertain
|
||||
.as_ref()
|
||||
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
|
||||
.or_else(|| {
|
||||
self.hosted
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|s| (s.connection.clone(), s.resource_id))
|
||||
});
|
||||
if let Some((connection, id)) = destination {
|
||||
return self.hosted_start(Operation::Check, connection, Some(id));
|
||||
}
|
||||
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
|
||||
if remote.compiled.bundle != request.snapshot.bundle {
|
||||
self.hosted.review = Some(Review {
|
||||
connection: request.connection.clone(),
|
||||
remote,
|
||||
});
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.visible = true;
|
||||
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
resource_id: remote.resource_id,
|
||||
revision: remote.revision,
|
||||
etag: remote.etag,
|
||||
});
|
||||
self.path = None;
|
||||
self.disk_project = None;
|
||||
self.document.mark_saved(request.snapshot.clone());
|
||||
self.hosted.uncertain = None;
|
||||
self.hosted.review = None;
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.error = None;
|
||||
self.status = format!(
|
||||
"Hosted revision {} saved. Undo remains local until your next Save.",
|
||||
remote.revision
|
||||
);
|
||||
// A late receipt may save an older snapshot; it must not close newer work.
|
||||
if self.continue_after_save {
|
||||
self.continue_after_save = false;
|
||||
if request.revision == self.document.revision()
|
||||
&& !self.dirty()
|
||||
&& let Some(pending) = self.pending.take()
|
||||
{
|
||||
return self.proceed(pending);
|
||||
}
|
||||
self.pending = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Show => {
|
||||
self.hosted.visible = true;
|
||||
self.model_menu = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::Close => self.hosted.visible = false,
|
||||
Event::Base(base) => {
|
||||
self.hosted.base = base;
|
||||
self.hosted.connection = None;
|
||||
self.hosted.catalog.clear();
|
||||
self.hosted.invalidate_catalog();
|
||||
self.hosted.selected = None;
|
||||
}
|
||||
Event::Token(token) => {
|
||||
self.hosted.token = token;
|
||||
self.hosted.connection = None;
|
||||
self.hosted.catalog.clear();
|
||||
self.hosted.selected = None;
|
||||
self.hosted.invalidate_catalog();
|
||||
}
|
||||
Event::Query(query) => {
|
||||
if self.hosted.query != query {
|
||||
self.hosted.query = query;
|
||||
self.hosted.selected = None;
|
||||
self.hosted.invalidate_catalog();
|
||||
}
|
||||
}
|
||||
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
|
||||
Ok(connection) => {
|
||||
self.hosted.invalidate_catalog();
|
||||
return self.hosted_start(Operation::Catalog(false), connection, None);
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.detail.clone();
|
||||
self.hosted.error = Some(error);
|
||||
}
|
||||
},
|
||||
Event::More => {
|
||||
if !self.hosted.catalog_stale
|
||||
&& self.hosted.next.is_some()
|
||||
&& let Some(connection) = &self.hosted.connection
|
||||
{
|
||||
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
|
||||
}
|
||||
}
|
||||
Event::Select(id) => self.hosted.selected = Some(id),
|
||||
Event::OpenSelected => {
|
||||
if let (Some(connection), Some(id)) =
|
||||
(&self.hosted.connection, self.hosted.selected)
|
||||
{
|
||||
self.hosted.open_target = Some((connection.clone(), id));
|
||||
self.hosted.visible = false;
|
||||
return self.handle(Message::Request(Pending::HostedOpen));
|
||||
}
|
||||
}
|
||||
Event::Publish => {
|
||||
if !self.hosted_ready() {
|
||||
return Task::none();
|
||||
}
|
||||
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
self.status = error.detail.clone();
|
||||
self.hosted.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
return self.hosted_start(Operation::Publish, connection, None);
|
||||
}
|
||||
Event::Save => return self.save_hosted(),
|
||||
Event::Check => return self.check_hosted(),
|
||||
Event::ReviewFile(path) => {
|
||||
if let Some(source) = self
|
||||
.hosted
|
||||
.review
|
||||
.as_ref()
|
||||
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
|
||||
{
|
||||
self.hosted.review_editor = text_editor::Content::with_text(source);
|
||||
self.hosted.review_path = Some(path);
|
||||
}
|
||||
}
|
||||
Event::ReviewAction(action) => {
|
||||
if !action.is_edit() {
|
||||
self.hosted.review_editor.perform(action);
|
||||
}
|
||||
}
|
||||
Event::ReviewBack => self.hosted.review_path = None,
|
||||
Event::Reload => {
|
||||
if !self.hosted_ready() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(review) = self.hosted.review.clone() {
|
||||
let remote = review.remote;
|
||||
let connection = review.connection;
|
||||
{
|
||||
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||
Ok(_) => {
|
||||
self.document.mark_saved(remote.compiled.clone());
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
resource_id: remote.resource_id,
|
||||
revision: remote.revision,
|
||||
etag: remote.etag,
|
||||
});
|
||||
self.path = None;
|
||||
self.disk_project = None;
|
||||
self.hosted.review = None;
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.uncertain = None;
|
||||
self.hosted.error = None;
|
||||
self.sync();
|
||||
self.hosted.visible = false;
|
||||
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
|
||||
}
|
||||
Err(error) => self.hosted_problem(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Local => {
|
||||
self.hosted.visible = false;
|
||||
return self.handle(Message::Save(true));
|
||||
}
|
||||
Event::Forget => {
|
||||
if self.hosted.session.is_some() {
|
||||
self.hosted_problem("Save as local before disconnecting this hosted model.");
|
||||
} else {
|
||||
self.hosted = Default::default();
|
||||
self.hosted.visible = true;
|
||||
}
|
||||
}
|
||||
Event::Completed(id, document, result) => {
|
||||
let Some(request) = self.hosted.request.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if request.id != id
|
||||
|| request.document != document
|
||||
|| document != self.canvas_document
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let request = self.hosted.request.take().expect("matching request");
|
||||
self.busy = false;
|
||||
// A catalog is a read snapshot, never a live mirror. Known or
|
||||
// uncertain writes and observed newer revisions invalidate only
|
||||
// the service whose results are currently displayed.
|
||||
let catalog_changed = match &result {
|
||||
Ok(Outcome::Remote(remote)) => {
|
||||
matches!(request.operation, Operation::Publish | Operation::Save)
|
||||
// A changed bound head can enter/leave the active
|
||||
// filter or page even when no row was displayed.
|
||||
|| self.hosted.session.as_ref().is_some_and(|session| {
|
||||
session.connection.base() == request.connection.base()
|
||||
&& session.resource_id == remote.resource_id
|
||||
&& session.etag != remote.etag
|
||||
})
|
||||
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
|
||||
failed.operation == Operation::Save
|
||||
&& failed.connection.base() == request.connection.base()
|
||||
&& failed.resource_id == Some(remote.resource_id)
|
||||
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
|
||||
})
|
||||
|| self.hosted.catalog.iter().any(|item| {
|
||||
item.resource_id == remote.resource_id
|
||||
&& item.revision != remote.revision
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
matches!(request.operation, Operation::Publish | Operation::Save)
|
||||
&& (error.uncertain || error.status == Some(412))
|
||||
}
|
||||
Ok(Outcome::Catalog(_)) => false,
|
||||
};
|
||||
if catalog_changed
|
||||
&& self
|
||||
.hosted
|
||||
.connection
|
||||
.as_ref()
|
||||
.is_some_and(|connection| connection.base() == request.connection.base())
|
||||
{
|
||||
self.hosted.invalidate_catalog();
|
||||
}
|
||||
match result {
|
||||
Err(error) => {
|
||||
let conflict =
|
||||
error.status == Some(412) && request.operation == Operation::Save;
|
||||
if error.uncertain || conflict {
|
||||
let mut failed = request.clone();
|
||||
if let Some(id) = error.published {
|
||||
failed.resource_id = Some(id);
|
||||
}
|
||||
if matches!(request.operation, Operation::Save | Operation::Publish) {
|
||||
self.hosted.uncertain = Some(failed);
|
||||
}
|
||||
}
|
||||
self.status = error.detail.clone();
|
||||
self.hosted.error = Some(error);
|
||||
self.hosted.visible = true;
|
||||
self.continue_after_save = false;
|
||||
self.pending = None;
|
||||
if conflict {
|
||||
return self.check_hosted();
|
||||
}
|
||||
}
|
||||
Ok(Outcome::Catalog(catalog)) => {
|
||||
if let Some(session) = &mut self.hosted.session
|
||||
&& session.connection.base() == request.connection.base()
|
||||
{
|
||||
session.connection = request.connection.clone();
|
||||
}
|
||||
if let Some(failed) = &mut self.hosted.uncertain
|
||||
&& failed.connection.base() == request.connection.base()
|
||||
{
|
||||
failed.connection = request.connection.clone();
|
||||
}
|
||||
if let Some(review) = &mut self.hosted.review
|
||||
&& review.connection.base() == request.connection.base()
|
||||
{
|
||||
review.connection = request.connection.clone();
|
||||
}
|
||||
self.hosted.connection = Some(request.connection);
|
||||
if request.operation != Operation::Catalog(true) {
|
||||
self.hosted.catalog.clear();
|
||||
}
|
||||
for item in catalog.items {
|
||||
if !self
|
||||
.hosted
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|old| old.resource_id == item.resource_id)
|
||||
{
|
||||
self.hosted.catalog.push(item);
|
||||
}
|
||||
}
|
||||
self.hosted.next = catalog.next_after;
|
||||
self.hosted.catalog_stale = false;
|
||||
if !self
|
||||
.hosted
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|item| Some(item.resource_id) == self.hosted.selected)
|
||||
{
|
||||
self.hosted.selected = None;
|
||||
}
|
||||
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
|
||||
}
|
||||
Ok(Outcome::Remote(remote)) => match request.operation {
|
||||
Operation::Open(_) => {
|
||||
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
|
||||
Ok(document) => {
|
||||
self.reset(document.project().clone(), None);
|
||||
self.document = document;
|
||||
self.source_workspace =
|
||||
Workspace::new(self.document.bundle(), true);
|
||||
self.sync();
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection,
|
||||
resource_id: remote.resource_id,
|
||||
revision: remote.revision,
|
||||
etag: remote.etag,
|
||||
});
|
||||
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
|
||||
}
|
||||
Err(error) => self.hosted_problem(error.to_string()),
|
||||
}
|
||||
}
|
||||
Operation::Publish | Operation::Save => {
|
||||
return self.acknowledge_hosted(&request, remote);
|
||||
}
|
||||
Operation::Check => {
|
||||
let acknowledged = self
|
||||
.hosted
|
||||
.uncertain
|
||||
.as_ref()
|
||||
.filter(|failed| {
|
||||
failed.connection.base() == request.connection.base()
|
||||
&& failed.resource_id == Some(remote.resource_id)
|
||||
&& failed.snapshot.bundle == remote.compiled.bundle
|
||||
})
|
||||
.cloned();
|
||||
if let Some(failed) = acknowledged {
|
||||
return self.acknowledge_hosted(&failed, remote);
|
||||
}
|
||||
if self.hosted.session.as_ref().is_some_and(|session| {
|
||||
session.connection.base() == request.connection.base()
|
||||
&& session.resource_id == remote.resource_id
|
||||
&& session.etag == remote.etag
|
||||
}) {
|
||||
self.hosted.review = None;
|
||||
self.hosted.review_path = None;
|
||||
self.status =
|
||||
"The server revision is unchanged. Local edits are retained."
|
||||
.into();
|
||||
} else {
|
||||
self.status = format!(
|
||||
"Server revision {} is available. Review before replacing local work.",
|
||||
remote.revision
|
||||
);
|
||||
self.hosted.review = Some(Review {
|
||||
connection: request.connection.clone(),
|
||||
remote,
|
||||
});
|
||||
self.hosted.review_path = None;
|
||||
self.hosted.visible = true;
|
||||
}
|
||||
}
|
||||
Operation::Catalog(_) => {
|
||||
self.hosted_problem("Unexpected response to catalog request.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,817 @@
|
||||
use super::*;
|
||||
use crate::hosted::client::Secret;
|
||||
|
||||
fn client() -> Connection {
|
||||
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Hosted(event));
|
||||
}
|
||||
fn rename(app: &mut App, name: &str) {
|
||||
app.document
|
||||
.execute(Command::Rename(app.project().id, name.into()))
|
||||
.unwrap();
|
||||
app.sync();
|
||||
}
|
||||
fn bind(app: &mut App) -> Id {
|
||||
let resource = Id::new_v4();
|
||||
app.hosted.session = Some(Session {
|
||||
connection: client(),
|
||||
resource_id: resource,
|
||||
revision: 1,
|
||||
etag: "\"opaque-base\"".into(),
|
||||
});
|
||||
resource
|
||||
}
|
||||
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
|
||||
Remote {
|
||||
resource_id,
|
||||
revision,
|
||||
etag: format!("\"opaque-{revision}\""),
|
||||
compiled: snapshot,
|
||||
}
|
||||
}
|
||||
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
|
||||
let r = app.hosted.request.as_ref().unwrap();
|
||||
send(app, Event::Completed(r.id, r.document, result));
|
||||
}
|
||||
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
|
||||
Failure {
|
||||
detail: "Test failure".into(),
|
||||
code: "test".into(),
|
||||
status,
|
||||
uncertain,
|
||||
published: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
rename(&mut app, "Published revision");
|
||||
app.path = Some(PathBuf::from("local.arcadia"));
|
||||
send(&mut app, Event::Publish);
|
||||
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
|
||||
assert!(app.busy);
|
||||
rename(&mut app, "Later local edit");
|
||||
let resource = Id::new_v4();
|
||||
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
|
||||
assert!(!app.busy);
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||
assert!(app.path.is_none());
|
||||
assert_eq!(app.project().name, "Later local edit");
|
||||
assert!(app.document.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project().name, "Published revision");
|
||||
assert!(!app.document.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||
other
|
||||
.execute(Command::Rename(other.project().id, "Other engineer".into()))
|
||||
.unwrap();
|
||||
rename(&mut app, "Local engineering work");
|
||||
let local = app.document.snapshot();
|
||||
send(&mut app, Event::Save);
|
||||
complete(&mut app, Err(failure(Some(412), false)));
|
||||
assert_eq!(
|
||||
app.hosted.request.as_ref().unwrap().operation,
|
||||
Operation::Check
|
||||
);
|
||||
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
|
||||
send(&mut app, Event::Save);
|
||||
assert!(!app.busy);
|
||||
assert!(app.hosted.request.is_none());
|
||||
send(&mut app, Event::Reload);
|
||||
assert_eq!(app.project().name, "Other engineer");
|
||||
assert!(!app.document.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project().name, "Local engineering work");
|
||||
assert!(app.document.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
rename(&mut app, "Saved despite lost response");
|
||||
send(&mut app, Event::Save);
|
||||
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
|
||||
complete(&mut app, Err(failure(None, true)));
|
||||
assert!(app.document.dirty());
|
||||
assert!(!app.busy);
|
||||
assert!(app.hosted.request.is_none());
|
||||
send(&mut app, Event::Check);
|
||||
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
|
||||
assert!(!app.document.dirty());
|
||||
assert!(app.hosted.uncertain.is_none());
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
bind(&mut app);
|
||||
send(&mut app, Event::Save);
|
||||
let request = app.hosted.request.as_ref().unwrap().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
|
||||
);
|
||||
assert!(app.busy);
|
||||
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
|
||||
app.reset(Project::empty(), None);
|
||||
let current = app.document.snapshot();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Completed(
|
||||
request.id,
|
||||
request.document,
|
||||
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
|
||||
),
|
||||
);
|
||||
assert!(Arc::ptr_eq(¤t, &app.document.snapshot()));
|
||||
assert!(app.hosted.session.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
bind(&mut app);
|
||||
app.source_workspace
|
||||
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
|
||||
let draft = app.source_workspace.bundle();
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Event::Save);
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), draft);
|
||||
assert!(!app.busy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
rename(&mut app, "Keep this work");
|
||||
app.hosted.connection = Some(client());
|
||||
app.hosted.selected = Some(Id::new_v4());
|
||||
send(&mut app, Event::OpenSelected);
|
||||
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
|
||||
assert!(app.hosted.request.is_none());
|
||||
let _ = app.update(Message::Discard);
|
||||
let previous = app.document.snapshot();
|
||||
complete(&mut app, Err(failure(Some(401), false)));
|
||||
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
|
||||
assert_eq!(app.project().name, "Keep this work");
|
||||
assert!(app.document.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
rename(&mut app, "Local fallback");
|
||||
let failure = SaveFailure {
|
||||
document: app.canvas_document,
|
||||
message: "Disk failed".into(),
|
||||
recovery: None,
|
||||
};
|
||||
let _ = app.update(Message::Saved(Err(failure)));
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let target = directory.path().join(&app.document.bundle().entry);
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
target.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(app.hosted.session.is_none());
|
||||
assert_eq!(app.path, Some(target));
|
||||
assert!(!app.document.dirty());
|
||||
assert!(app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
send(&mut app, Event::Show);
|
||||
let original = app.document.snapshot();
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
app.project().id,
|
||||
"Underlying edit".into(),
|
||||
)));
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
let token = "a-secret-never-in-a-diagnostic";
|
||||
let event = Message::Hosted(Event::Token(Secret(token.into())));
|
||||
assert!(!format!("{event:?}").contains(token));
|
||||
let _ = app.update(event);
|
||||
send(&mut app, Event::Forget);
|
||||
assert!(app.hosted.token.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.insert("detached.arcadia".into(), "// kept draft".into());
|
||||
let reachable = app.document.bundle().files.keys().cloned().collect();
|
||||
app.source_workspace.discovered(&bundle, reachable);
|
||||
app.source_workspace
|
||||
.switch("detached.arcadia", app.source_editor.cursor());
|
||||
app.source_workspace.edit(
|
||||
"// unsaved detached rationale".into(),
|
||||
app.source_editor.cursor(),
|
||||
);
|
||||
send(&mut app, Event::Publish);
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert!(app.source_workspace.detached_dirty());
|
||||
assert_eq!(
|
||||
app.source_workspace.files["detached.arcadia"].text,
|
||||
"// unsaved detached rationale"
|
||||
);
|
||||
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
|
||||
send(&mut app, Event::Publish);
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert_eq!(
|
||||
app.pending_recovery,
|
||||
Some(PathBuf::from("interrupted.arcadia"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let old_id = bind(&mut app);
|
||||
app.hosted.base = "http://127.0.0.1:9876".into();
|
||||
rename(&mut app, "New publication");
|
||||
send(&mut app, Event::Publish);
|
||||
let new_id = Id::new_v4();
|
||||
let mut error = failure(None, true);
|
||||
error.published = Some(new_id);
|
||||
complete(&mut app, Err(error));
|
||||
send(&mut app, Event::Check);
|
||||
let request = app.hosted.request.as_ref().unwrap();
|
||||
assert_eq!(request.resource_id, Some(new_id));
|
||||
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
|
||||
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||
changed
|
||||
.execute(Command::Rename(
|
||||
changed.project().id,
|
||||
"Changed after publication".into(),
|
||||
))
|
||||
.unwrap();
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
|
||||
);
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
|
||||
send(&mut app, Event::Reload);
|
||||
let session = app.hosted.session.as_ref().unwrap();
|
||||
assert_eq!(session.resource_id, new_id);
|
||||
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
|
||||
assert_eq!(app.project().name, "Changed after publication");
|
||||
}
|
||||
|
||||
async fn dispatch(app: &mut App, message: Message) {
|
||||
use iced::futures::StreamExt;
|
||||
let mut tasks = vec![app.update(message)];
|
||||
while let Some(task) = tasks.pop() {
|
||||
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = stream.next().await {
|
||||
if let iced_runtime::Action::Output(message) = action {
|
||||
tasks.push(app.update(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
|
||||
let token = "current-secret-for-hosted-regression-0123456789";
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (stop, done) = tokio::sync::oneshot::channel::<()>();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, oa_server::app(store, Some(token.into())))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.insert_str(0, "// Hosted rationale\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
app.sync();
|
||||
app.hosted.base = base.clone();
|
||||
app.hosted.token = Secret(token.into());
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert!(app.hosted.catalog.is_empty());
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(
|
||||
!app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Close)).await;
|
||||
dispatch(&mut app, Message::Hosted(Event::Show)).await;
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(
|
||||
app.hosted.request.is_none(),
|
||||
"Showing the dialog does not poll"
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog.len(), 1);
|
||||
assert_eq!(app.hosted.catalog[0].revision, 1);
|
||||
dispatch(&mut app, Message::Hosted(Event::Close)).await;
|
||||
let session = app
|
||||
.hosted
|
||||
.session
|
||||
.clone()
|
||||
.expect("publish binds a resource");
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.dirty());
|
||||
assert!(
|
||||
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
|
||||
);
|
||||
let project_id = app.project().id;
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::Command(Command::Rename(project_id, "Graphical update".into())),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert_eq!(
|
||||
app.hosted.catalog[0].revision, 1,
|
||||
"Old results stay identified as a snapshot"
|
||||
);
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog[0].revision, 2);
|
||||
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
|
||||
let external = Connection::new(&base, Secret(token.into())).unwrap();
|
||||
let remote = external.open(session.resource_id).await.unwrap();
|
||||
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
|
||||
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
|
||||
other
|
||||
.execute(Command::Rename(project_id, "API engineering change".into()))
|
||||
.unwrap();
|
||||
external
|
||||
.save(session.resource_id, &remote.etag, other.bundle().clone())
|
||||
.await
|
||||
.unwrap();
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::Command(Command::Rename(project_id, "Local pending change".into())),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
assert!(app.hosted.review.is_some());
|
||||
assert_eq!(app.project().name, "Local pending change");
|
||||
assert!(app.document.dirty());
|
||||
// Simulate a retired credential in every preexisting reconciliation handle.
|
||||
let old =
|
||||
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
|
||||
app.hosted.session.as_mut().unwrap().connection = old.clone();
|
||||
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
|
||||
app.hosted.review.as_mut().unwrap().connection = old;
|
||||
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
|
||||
let reconciled_write = app.hosted.uncertain.clone().unwrap();
|
||||
// Reload the captured review directly. A fresh Check must not hide a stale
|
||||
// credential in that review by replacing it with a newer response.
|
||||
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
|
||||
assert_eq!(app.project().name, "API engineering change");
|
||||
dispatch(&mut app, Message::Undo).await;
|
||||
assert_eq!(app.project().name, "Local pending change");
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
assert!(!app.document.dirty());
|
||||
// Independently exercise the corrected uncertain-write connection.
|
||||
app.hosted.uncertain = Some(reconciled_write);
|
||||
dispatch(&mut app, Message::Hosted(Event::Check)).await;
|
||||
assert!(app.hosted.error.is_none());
|
||||
assert!(app.hosted.uncertain.is_none());
|
||||
let accepted = external.open(session.resource_id).await.unwrap();
|
||||
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
|
||||
assert_eq!(
|
||||
accepted.compiled.decoded.project.name,
|
||||
"Local pending change"
|
||||
);
|
||||
let mut second = App::with_project(Project::empty());
|
||||
second.hosted.base = base;
|
||||
second.hosted.token = Secret(token.into());
|
||||
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
|
||||
assert!(
|
||||
second
|
||||
.hosted
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|item| item.resource_id == session.resource_id)
|
||||
);
|
||||
send(&mut second, Event::Select(session.resource_id));
|
||||
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
|
||||
assert_eq!(second.document.bundle(), app.document.bundle());
|
||||
assert_eq!(
|
||||
second.hosted.session.as_ref().unwrap().resource_id,
|
||||
session.resource_id
|
||||
);
|
||||
dispatch(
|
||||
&mut second,
|
||||
Message::Command(Command::Rename(project_id, "Saved before New".into())),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut second, Message::Request(Pending::New)).await;
|
||||
assert!(second.pending.is_some());
|
||||
dispatch(&mut second, Message::SaveAndContinue).await;
|
||||
assert!(second.hosted.session.is_none());
|
||||
assert!(second.pending.is_none());
|
||||
assert_ne!(second.project().id, project_id);
|
||||
assert_eq!(
|
||||
external
|
||||
.open(session.resource_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.compiled
|
||||
.decoded
|
||||
.project
|
||||
.name,
|
||||
"Saved before New"
|
||||
);
|
||||
drop(external);
|
||||
drop(app);
|
||||
drop(second);
|
||||
let _ = stop.send(());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (stop, done) = tokio::sync::oneshot::channel::<()>();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, oa_server::app(store, None))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.hosted.base = base.clone();
|
||||
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
|
||||
let session = app.hosted.session.clone().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let original_identity = app.canvas_document;
|
||||
let external = Connection::new(&base, Secret::default()).unwrap();
|
||||
let old = external.open(session.resource_id).await.unwrap();
|
||||
|
||||
let local = tempfile::tempdir().unwrap();
|
||||
let path = local.path().join("repair.arcadia");
|
||||
let invalid = "arcadia 5; ? // unfinished local work";
|
||||
std::fs::write(&path, invalid).unwrap();
|
||||
let path = path.canonicalize().unwrap();
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(app.canvas_document, original_identity);
|
||||
assert_eq!(app.path.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.source_editor.text(), invalid);
|
||||
assert!(
|
||||
Arc::ptr_eq(&accepted, &app.document.snapshot()),
|
||||
"A repair workspace must preserve the last valid graphical model"
|
||||
);
|
||||
assert!(!app.source_diagnostics.is_empty());
|
||||
assert!(
|
||||
app.hosted.session.is_none(),
|
||||
"The installed local repair workspace must own its save destination"
|
||||
);
|
||||
|
||||
let mut repaired = Project::empty();
|
||||
repaired.name = "Independent local repair".into();
|
||||
let repaired_source = language::format(&repaired);
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||
)
|
||||
.await;
|
||||
dispatch(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(repaired_source),
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
dispatch(&mut app, Message::ApplySource).await;
|
||||
assert_eq!(app.project(), &repaired);
|
||||
assert!(!app.source_dirty());
|
||||
dispatch(&mut app, Message::Save(false)).await;
|
||||
|
||||
let remote_after = external.open(session.resource_id).await.unwrap();
|
||||
assert_eq!(
|
||||
remote_after.etag, old.etag,
|
||||
"Saving a repaired local workspace must never target the old hosted resource"
|
||||
);
|
||||
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
|
||||
assert!(app.hosted.session.is_none());
|
||||
assert!(app.hosted.review.is_none());
|
||||
assert!(app.hosted.uncertain.is_none());
|
||||
assert!(app.hosted.request.is_none());
|
||||
assert_eq!(app.path.as_ref(), Some(&path));
|
||||
assert!(!app.dirty());
|
||||
let saved = source_files::load_project(&path, LIMITS).unwrap();
|
||||
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
|
||||
assert_eq!(
|
||||
app.hosted.base, base,
|
||||
"Connection settings remain available in memory"
|
||||
);
|
||||
drop(external);
|
||||
drop(app);
|
||||
let _ = stop.send(());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
let accepted = app.document.snapshot();
|
||||
let identity = app.canvas_document;
|
||||
let source = app.source_workspace.bundle();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let unreadable = directory.path().join("missing.json");
|
||||
let error = read_snapshot(unreadable).unwrap_err();
|
||||
for result in [Ok(None), Err(error)] {
|
||||
let _ = app.update(Message::Loaded(result));
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.canvas_document, identity);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(app.path.is_none());
|
||||
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
|
||||
Outcome::Catalog(crate::hosted::client::Catalog {
|
||||
items: vec![crate::hosted::client::Summary {
|
||||
resource_id: resource,
|
||||
revision,
|
||||
model_id: app.project().id,
|
||||
name: format!("Catalog snapshot {revision}"),
|
||||
elements: app.project().elements.len(),
|
||||
relations: app.project().relations.len(),
|
||||
}],
|
||||
next_after: Some(resource),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let first = Id::new_v4();
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, first, 1);
|
||||
complete(&mut app, Ok(catalog));
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
send(&mut app, Event::More);
|
||||
assert_eq!(
|
||||
app.hosted.request.as_ref().unwrap().operation,
|
||||
Operation::Catalog(true)
|
||||
);
|
||||
let next_page = catalog_result(&app, Id::new_v4(), 1);
|
||||
complete(&mut app, Ok(next_page));
|
||||
assert_eq!(app.hosted.catalog.len(), 2);
|
||||
send(&mut app, Event::Select(first));
|
||||
send(&mut app, Event::Query("different search".into()));
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.next.is_none());
|
||||
assert!(app.hosted.selected.is_none());
|
||||
assert_eq!(app.hosted.catalog.len(), 2);
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.contains("Connect / refresh")
|
||||
);
|
||||
send(&mut app, Event::More);
|
||||
assert!(app.hosted.request.is_none());
|
||||
send(&mut app, Event::Connect);
|
||||
complete(&mut app, Err(failure(None, false)));
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog[0].resource_id, first);
|
||||
send(&mut app, Event::Connect);
|
||||
let second = Id::new_v4();
|
||||
let catalog = catalog_result(&app, second, 1);
|
||||
complete(&mut app, Ok(catalog));
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert_eq!(app.hosted.catalog.len(), 1);
|
||||
assert_eq!(app.hosted.catalog[0].resource_id, second);
|
||||
send(&mut app, Event::Query("different search".into()));
|
||||
assert!(
|
||||
!app.hosted.catalog_stale,
|
||||
"Unchanged query preserves the snapshot"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Token(Secret("different-credential".into())),
|
||||
);
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.catalog.is_empty());
|
||||
assert!(app.hosted.connection.is_none());
|
||||
assert!(app.hosted.next.is_none());
|
||||
assert!(app.hosted.selected.is_none());
|
||||
assert!(
|
||||
!app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, second, 2);
|
||||
complete(&mut app, Ok(catalog));
|
||||
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||
assert!(app.hosted.catalog.is_empty());
|
||||
assert!(app.hosted.connection.is_none());
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.request.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
let other = Id::new_v4();
|
||||
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, other, 7);
|
||||
complete(&mut app, Ok(catalog));
|
||||
rename(&mut app, "Saved to original service");
|
||||
send(&mut app, Event::Save);
|
||||
let saved = remote(app.document.snapshot(), resource, 2);
|
||||
complete(&mut app, Ok(Outcome::Remote(saved)));
|
||||
assert!(
|
||||
!app.hosted.catalog_stale,
|
||||
"A write to A cannot invalidate B's catalog"
|
||||
);
|
||||
assert_eq!(app.hosted.next, Some(other));
|
||||
assert_eq!(app.hosted.catalog[0].resource_id, other);
|
||||
|
||||
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, resource, 2);
|
||||
complete(&mut app, Ok(catalog));
|
||||
send(&mut app, Event::Check);
|
||||
let changed = remote(app.document.snapshot(), resource, 3);
|
||||
complete(&mut app, Ok(Outcome::Remote(changed)));
|
||||
assert!(
|
||||
app.hosted.catalog_stale,
|
||||
"A known newer server revision invalidates old metadata"
|
||||
);
|
||||
assert!(app.hosted.next.is_none());
|
||||
send(&mut app, Event::Connect);
|
||||
let catalog = catalog_result(&app, resource, 3);
|
||||
complete(&mut app, Ok(catalog));
|
||||
send(&mut app, Event::Reload);
|
||||
rename(&mut app, "Write with uncertain outcome");
|
||||
send(&mut app, Event::Save);
|
||||
complete(&mut app, Err(failure(None, true)));
|
||||
assert!(app.hosted.catalog_stale);
|
||||
assert!(app.hosted.next.is_none());
|
||||
assert_eq!(app.hosted.catalog[0].revision, 3);
|
||||
app.hosted.leave_document();
|
||||
assert!(
|
||||
app.hosted.catalog_stale,
|
||||
"Leaving the hosted destination cannot make old results fresh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
rename(&mut app, "Original");
|
||||
let resource = bind(&mut app);
|
||||
send(&mut app, Event::Query("Renamed".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
|
||||
items: vec![],
|
||||
next_after: None,
|
||||
})),
|
||||
);
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
|
||||
let mut unchanged = remote(app.document.snapshot(), resource, 1);
|
||||
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
|
||||
send(&mut app, Event::Check);
|
||||
complete(&mut app, Ok(Outcome::Remote(unchanged)));
|
||||
assert!(
|
||||
!app.hosted.catalog_stale,
|
||||
"An unchanged bound head keeps the filtered snapshot fresh"
|
||||
);
|
||||
|
||||
// A different engineer changes the hosted model so it now matches the filter.
|
||||
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
|
||||
changed
|
||||
.execute(Command::Rename(changed.project().id, "Renamed".into()))
|
||||
.unwrap();
|
||||
send(&mut app, Event::Check);
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
|
||||
);
|
||||
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
|
||||
assert!(
|
||||
app.hosted.request.is_none(),
|
||||
"No polling or automatic catalog request"
|
||||
);
|
||||
assert!(
|
||||
app.hosted.catalog_stale,
|
||||
"Known newer revision can change membership in an empty filtered catalog"
|
||||
);
|
||||
assert!(
|
||||
!app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let resource = bind(&mut app);
|
||||
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
|
||||
send(&mut app, Event::Query("Renamed".into()));
|
||||
send(&mut app, Event::Connect);
|
||||
complete(
|
||||
&mut app,
|
||||
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
|
||||
items: vec![],
|
||||
next_after: None,
|
||||
})),
|
||||
);
|
||||
send(&mut app, Event::Check);
|
||||
let changed = remote(app.document.snapshot(), resource, 2);
|
||||
complete(&mut app, Ok(Outcome::Remote(changed)));
|
||||
assert!(app.hosted.review.is_some());
|
||||
assert!(!app.hosted.catalog_stale);
|
||||
assert!(
|
||||
app.hosted
|
||||
.catalog_message()
|
||||
.unwrap()
|
||||
.starts_with("No models found")
|
||||
);
|
||||
assert!(app.hosted.request.is_none());
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::{self, CanvasEvent};
|
||||
impl App {
|
||||
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
|
||||
let owner = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& matches!(
|
||||
self.project().elements.get(&owner)?.kind(),
|
||||
Kind::Process | Kind::FunctionalChain
|
||||
))
|
||||
.then_some(topology::Context {
|
||||
document: self.canvas_document,
|
||||
owner,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(offset) =
|
||||
self.behavior
|
||||
.topology
|
||||
.selection_offset(self.project(), context.owner, self.selected)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
iced::widget::operation::scroll_to("process-canvas", offset)
|
||||
}
|
||||
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.scrolled(area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Viewport(_, size) = event {
|
||||
if self.topology_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.owner == context.owner
|
||||
&& c.revision == context.revision
|
||||
}) && self.behavior.topology.viewport(size)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context() != Some(context) {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
|
||||
{
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.topology.choose(tool);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, zoom) => {
|
||||
self.behavior.topology.zoom(zoom);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Expand(_, id) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand(self.project(), context.owner, id);
|
||||
self.behavior.topology = state;
|
||||
if changed {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Open(_, occurrence) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(target) =
|
||||
topology::referenced(self.project(), context.owner, occurrence)
|
||||
{
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&target)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
{
|
||||
self.behavior.topology.remember(context.owner, occurrence);
|
||||
self.behavior.active_owner = Some(target);
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.select(target);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Back(_) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let back = state.back(self.project());
|
||||
self.behavior.topology = state;
|
||||
if let Some((owner, occurrence)) = back {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.select(occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the process.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::Begin {
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.begin(self.project(), context, tool, control, insertion);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status="Choose the contextual contribution and apply at the captured position.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::AppendExchange(_) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.append_exchange(self.project(), context.owner);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Connect { source, target, .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let result = state.connect(self.project(), context.owner, source, target);
|
||||
self.behavior.topology = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Describe the path condition, then Create.".into();
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reconnect {
|
||||
link, end, target, ..
|
||||
} => {
|
||||
match topology::reconnect(self.project(), context.owner, link, end, target)
|
||||
{
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = link;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Reorder {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = e,
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
topology::{
|
||||
CanvasTool, Event, Insertion, Tool,
|
||||
direct_tests::{self, Fixture},
|
||||
},
|
||||
};
|
||||
fn setup() -> (App, Fixture) {
|
||||
let f = direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
(app, f)
|
||||
}
|
||||
fn form(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
|
||||
}
|
||||
fn begin(app: &App) -> CanvasEvent {
|
||||
CanvasEvent::Begin {
|
||||
context: app.topology_context().unwrap(),
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = direct_tests::empty_exchange_fixture(layer);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let empty = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Exchange,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
}));
|
||||
assert!(app.behavior.has_pending());
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.exchange,
|
||||
label: "Assessment".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("First contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let first_id = app.selected;
|
||||
let first = app.document.snapshot();
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id]
|
||||
);
|
||||
assert_eq!(app.project().relations, empty.decoded.project.relations);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
|
||||
context,
|
||||
)));
|
||||
assert!(
|
||||
app.behavior.has_pending(),
|
||||
"reachable single-exchange continuation: {}",
|
||||
app.status
|
||||
);
|
||||
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: next,
|
||||
label: "Response".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Second contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending(), "{}", app.status);
|
||||
let second_id = app.selected;
|
||||
let second = app.document.snapshot();
|
||||
assert_ne!(first_id, second_id);
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[first_id, second_id]
|
||||
);
|
||||
let links = app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::SequenceLink)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert_eq!(
|
||||
(links[0].source(), links[0].target()),
|
||||
(first_id, second_id)
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements.len(),
|
||||
empty.decoded.project.elements.len() + 2
|
||||
);
|
||||
for (id, relation) in &empty.decoded.project.relations {
|
||||
assert_eq!(app.project().relations[id], *relation);
|
||||
}
|
||||
for work in f.work {
|
||||
assert_eq!(
|
||||
app.project().elements[&work],
|
||||
empty.decoded.project.elements[&work]
|
||||
);
|
||||
}
|
||||
let reopened = Document::from_bundle(
|
||||
second.bundle.clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.bundle(), &second.bundle);
|
||||
for expected in [&first, &empty] {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"each Create must add exactly one history entry"
|
||||
);
|
||||
for expected in [&first, &second] {
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
}
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
|
||||
let (mut app, _) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
for field in 0..4 {
|
||||
let mut event = begin(&app);
|
||||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||||
unreachable!()
|
||||
};
|
||||
match field {
|
||||
0 => context.document = Id::new_v4(),
|
||||
1 => context.owner = Id::new_v4(),
|
||||
2 => context.revision += 1,
|
||||
_ => context.epoch += 1,
|
||||
};
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.draft_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
for event in [
|
||||
CanvasEvent::Zoom(context, Some(0.7)),
|
||||
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
|
||||
let (mut app, f) = setup();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes =
|
||||
bundle.files.values().map(String::len).sum::<usize>() + 3000;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
app.sync();
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(
|
||||
&mut app,
|
||||
Event::Target(Choice {
|
||||
id: f.work[0],
|
||||
label: "Assess".into(),
|
||||
}),
|
||||
);
|
||||
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
form(&mut app, Event::Name("Recovered contribution".into()));
|
||||
form(&mut app, Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_ne!(app.selected, f.owner);
|
||||
assert_eq!(app.project().named(app.selected), "Recovered contribution");
|
||||
let created = app.selected;
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert!(app.project().contains(created));
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Connect,
|
||||
)));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
context, f.nodes[0],
|
||||
)));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let other = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let other_id = other.id;
|
||||
assert!(app.execute(Command::CreateElement(other)));
|
||||
let _ = app.update(Message::BehaviorOwner(Choice {
|
||||
id: other_id,
|
||||
label: "Other".into(),
|
||||
}));
|
||||
assert_eq!(app.topology_context().unwrap().owner, other_id);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
|
||||
context,
|
||||
source: f.nodes[0],
|
||||
target: f.nodes[2],
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
|
||||
let (mut app, f) = setup();
|
||||
let context = app.topology_context().unwrap();
|
||||
let original = app.document.snapshot();
|
||||
app.busy = true;
|
||||
for (area, offset) in [
|
||||
(
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(25., 100.),
|
||||
),
|
||||
(
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 40.),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context, area, offset,
|
||||
)));
|
||||
}
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(500., 300.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
assert!(app.busy);
|
||||
let mut stale = context;
|
||||
stale.document = Id::new_v4();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
stale,
|
||||
behavior::scenario::ScrollArea::Canvas,
|
||||
iced::Vector::new(0., 900.),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.behavior.topology.scroll_offset(),
|
||||
iced::Vector::new(25., 140.)
|
||||
);
|
||||
let root = PathBuf::from("process-scroll-save-fixture");
|
||||
let receipt = FileSnapshot {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
snapshot: original.clone(),
|
||||
path: root.join(&original.bundle.entry),
|
||||
disk: Some(source_files::DiskProject {
|
||||
root,
|
||||
bundle: original.bundle.clone(),
|
||||
expectations: Default::default(),
|
||||
reachable: original.bundle.files.keys().cloned().collect(),
|
||||
diagnostics: vec![],
|
||||
}),
|
||||
};
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(!app.busy);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(app.project(), &f.p);
|
||||
let context = app.topology_context().unwrap();
|
||||
let size = app.window_size;
|
||||
let _ = app.update(Message::WindowResized(size));
|
||||
assert_eq!(app.topology_context(), Some(context));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||||
size.width + 20.,
|
||||
size.height,
|
||||
)));
|
||||
assert_ne!(app.topology_context(), Some(context));
|
||||
let event = begin(&app);
|
||||
let _ = app.update(Message::TopologyCanvas(event));
|
||||
form(&mut app, Event::Name("Keep this form".into()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
|
||||
context,
|
||||
behavior::scenario::ScrollArea::Page,
|
||||
iced::Vector::new(0., 80.),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
}
|
||||
#[test]
|
||||
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
|
||||
let (mut app, f) = setup();
|
||||
let source = app
|
||||
.project()
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
|
||||
.unwrap();
|
||||
let source_id = source.id;
|
||||
assert!(app.execute(Command::CreateElement(source)));
|
||||
let mut occurrence = app
|
||||
.project()
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
|
||||
.unwrap();
|
||||
let occurrence_id = occurrence.id;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
|
||||
*body = OccurrenceBody::SequenceReference {
|
||||
sequence: Some(source_id),
|
||||
};
|
||||
}
|
||||
assert!(app.execute(Command::CreateElement(occurrence)));
|
||||
let snapshot = app.document.snapshot();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
|
||||
context,
|
||||
occurrence_id,
|
||||
)));
|
||||
assert_eq!(app.selected, source_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, source_id);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(app.selected, occurrence_id);
|
||||
assert_eq!(app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let original = app.document.snapshot();
|
||||
let edited = format!(
|
||||
"{}\n// Retained source draft\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(edited.clone().into()),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(app.source_workspace.text(), edited);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
}
|
||||
+1668
-58
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,16 @@
|
||||
//! Process/chain authoring and projection use contextual occurrences and explicit
|
||||
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
mod direct;
|
||||
mod view;
|
||||
use direct::Input;
|
||||
pub(crate) use direct::{
|
||||
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) mod direct_tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -40,9 +48,8 @@ pub enum Event {
|
||||
Guard(String),
|
||||
AlternateGuard(String),
|
||||
Control(ControlChoice),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
Search(usize, String),
|
||||
Page(usize, usize),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ControlChoice(pub ControlKind);
|
||||
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
return_to: Vec<(Id, Id)>,
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
fit: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
struct Draft {
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
browsers: [Browser; 4],
|
||||
owner: Id,
|
||||
tool: Tool,
|
||||
name: String,
|
||||
@@ -99,9 +120,6 @@ impl State {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
Event::Start(owner, tool, selected) => {
|
||||
let e = p
|
||||
.elements
|
||||
@@ -127,6 +145,9 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool,
|
||||
name: String::new(),
|
||||
@@ -154,6 +175,11 @@ impl State {
|
||||
Event::Guard(value) => d.guard = value,
|
||||
Event::AlternateGuard(value) => d.alternate_guard = value,
|
||||
Event::Control(value) => d.control = value.0,
|
||||
Event::Search(index, value) if index < 4 => {
|
||||
d.browsers[index].search = value;
|
||||
d.browsers[index].page = 0;
|
||||
}
|
||||
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -163,12 +189,13 @@ impl State {
|
||||
}
|
||||
|
||||
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
||||
self.zoom.unwrap_or_else(|| {
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
})
|
||||
if !self.fit {
|
||||
return self.zoom.unwrap_or(1.);
|
||||
}
|
||||
let width = self.viewport.map_or(fallback_width, |s| s.width);
|
||||
((width - 24.).max(1.) / graph.width.max(1.))
|
||||
.min(450. / graph.height.max(1.))
|
||||
.clamp(0.05, 1.)
|
||||
}
|
||||
}
|
||||
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
||||
@@ -247,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
|
||||
})
|
||||
.map(|e| Choice {
|
||||
id: e.id,
|
||||
label: format!("{} · {}", e.name, e.alias),
|
||||
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
fn semantic_path(p: &Project, id: Id) -> String {
|
||||
let mut path = Vec::new();
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
let Some(e) = p.elements.get(&id) else { break };
|
||||
path.push(e.alias.as_str());
|
||||
current = e.parent;
|
||||
}
|
||||
path.reverse();
|
||||
path.join(" / ")
|
||||
}
|
||||
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
|
||||
p.elements
|
||||
.get(&owner)
|
||||
@@ -394,6 +432,40 @@ impl Draft {
|
||||
if self.before.is_some() && self.before == self.after {
|
||||
return Err("Choose different preceding and following contributions.".into());
|
||||
}
|
||||
let captured = self
|
||||
.insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, self.owner, r.source())
|
||||
&& is_contribution(p, self.owner, r.target())
|
||||
})
|
||||
.ok_or("The captured path no longer exists in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if captured
|
||||
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
|
||||
{
|
||||
return Err("The captured path changed. Choose its position again.".into());
|
||||
}
|
||||
let matches: Vec<_> = sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
|
||||
.collect();
|
||||
let split_link = if self.tool == Tool::Connect {
|
||||
None
|
||||
} else if captured.is_some() {
|
||||
captured
|
||||
} else if !self.fixed && matches.len() == 1 {
|
||||
Some(matches[0])
|
||||
} else if !self.fixed && matches.len() > 1 {
|
||||
return Err("Select the specific sequence link to insert on.".into());
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut commands = preserved_order(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -418,7 +490,6 @@ impl Draft {
|
||||
{
|
||||
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
|
||||
}
|
||||
require_guard(p, source, &self.guard)?;
|
||||
commands.push(link(p, source, target, &self.guard));
|
||||
return Ok(unique_batch(p, commands));
|
||||
}
|
||||
@@ -459,12 +530,6 @@ impl Draft {
|
||||
return Err("Choose same-layer work for the second path.".into());
|
||||
}
|
||||
let parallel = self.control == ControlKind::Fork;
|
||||
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Describe both alternative guards; use 'else' for the fallback path.".into(),
|
||||
);
|
||||
}
|
||||
let split = occurrence(
|
||||
p,
|
||||
self.owner,
|
||||
@@ -552,30 +617,47 @@ impl Draft {
|
||||
commands.push(Command::CreateElement(e));
|
||||
(id, id)
|
||||
};
|
||||
if let Some(before) = self.before {
|
||||
// Branch guards belong to the split's two outgoing paths. A branch
|
||||
// inserted after another choice must first use an explicit link.
|
||||
let guard = if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
};
|
||||
require_guard(p, before, guard)?;
|
||||
commands.push(link(p, before, first, guard));
|
||||
if let Some(existing) = split_link {
|
||||
let mut updated = existing.clone();
|
||||
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
|
||||
*target = first;
|
||||
}
|
||||
commands.push(Command::EditRelation(existing.id, updated));
|
||||
} else if let Some(before) = self.before {
|
||||
commands.push(link(
|
||||
p,
|
||||
before,
|
||||
first,
|
||||
if self.tool == Tool::Branch {
|
||||
""
|
||||
} else {
|
||||
&self.guard
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(after) = self.after {
|
||||
commands.push(link(p, last, after, ""));
|
||||
}
|
||||
// Inserting between two selected contributions replaces their direct
|
||||
// precedence while preserving other links and their semantic identities.
|
||||
if let (Some(before), Some(after)) = (self.before, self.after) {
|
||||
commands.extend(
|
||||
sequence_links(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|r| r.source() == before && r.target() == after)
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
}
|
||||
let new_ids: Vec<_> = commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut order = owner.steps().expect("process steps").clone();
|
||||
let slot = self.insertion.before.or(self.after);
|
||||
let index = slot
|
||||
.map(|id| {
|
||||
order
|
||||
.iter()
|
||||
.position(|v| *v == id)
|
||||
.ok_or("The presentation slot changed.")
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(order.len());
|
||||
order.splice(index..index, new_ids);
|
||||
commands.push(Command::SetSteps(self.owner, order));
|
||||
Ok(unique_batch(p, commands))
|
||||
}
|
||||
}
|
||||
@@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
|
||||
}
|
||||
Command::Batch(commands)
|
||||
}
|
||||
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
|
||||
if guard.trim().is_empty()
|
||||
&& matches!(
|
||||
p.elements.get(&source).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice | ControlKind::Loop
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
Err("Describe this choice / repeat condition, or enter 'else'.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(e: Event) -> Message {
|
||||
Message::Behavior(if matches!(e, Event::Submit) {
|
||||
super::Event::Submit
|
||||
@@ -622,307 +687,9 @@ fn event(e: Event) -> Message {
|
||||
super::Event::Topology(e)
|
||||
})
|
||||
}
|
||||
fn picker<'a>(
|
||||
choices: Vec<Choice>,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
pick_list(choices, selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill)
|
||||
.into()
|
||||
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
view::view(app, owner, out)
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
if !p.elements[&owner].description.is_empty() {
|
||||
out = out.push(
|
||||
text(&p.elements[&owner].description)
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
|
||||
action(
|
||||
"+ Exchange",
|
||||
event(Event::Start(owner, Tool::Exchange, selected))
|
||||
),
|
||||
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
action(
|
||||
"+ Control",
|
||||
event(Event::Start(owner, Tool::Control, selected))
|
||||
),
|
||||
action(
|
||||
"Branch / parallel…",
|
||||
event(Event::Start(owner, Tool::Branch, selected))
|
||||
),
|
||||
action(
|
||||
"Connect…",
|
||||
event(Event::Start(owner, Tool::Connect, selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(owner)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if let Some(d) = app
|
||||
.behavior
|
||||
.topology
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
|
||||
let legacy = legacy_order(p, owner);
|
||||
if legacy.len() > 1 {
|
||||
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices: Vec<_> = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|choice| compatible_source(p, d, choice.id))
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d.target,
|
||||
"Choose an existing source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source name)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d.alternate,
|
||||
"Work on the second path…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form.push(
|
||||
row![
|
||||
text_input("First path condition", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
text_input("Second path condition", &d.alternate_guard)
|
||||
.on_input(|s| event(Event::AlternateGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.before,
|
||||
if d.tool == Tool::Connect {
|
||||
"From contribution…"
|
||||
} else {
|
||||
"After contribution (optional)…"
|
||||
},
|
||||
Event::Before
|
||||
),
|
||||
action("Clear", event(Event::ClearBefore)),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
picker(
|
||||
contributions(p, owner),
|
||||
d.after,
|
||||
if d.tool == Tool::Connect {
|
||||
"To contribution…"
|
||||
} else {
|
||||
"Before contribution (optional)…"
|
||||
},
|
||||
Event::After
|
||||
),
|
||||
action("Clear", event(Event::ClearAfter)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
if d.tool != Tool::Branch {
|
||||
form = form.push(
|
||||
text_input("Sequence guard (for a choice or loop)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if let Err(problem) = d.command(p) {
|
||||
form = form.push(label(problem));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|
||||
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
|
||||
{
|
||||
let mut graph = Graph::new(p, owner, app.selected);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(container(column![
|
||||
text("Start this process").size(18),
|
||||
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
|
||||
].spacing(12)).padding(24).width(Fill).style(style::card));
|
||||
return scrollable(out.padding(24)).into();
|
||||
} else if graph.edges.is_empty() {
|
||||
out = out.push(label(
|
||||
"No explicit sequence links. Connect contributions to describe topology.",
|
||||
));
|
||||
}
|
||||
graph.scale = app
|
||||
.behavior
|
||||
.topology
|
||||
.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
let scale = graph.scale;
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
out = out.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.))),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
} else {
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
let conversion = preserved_order(p, owner, None);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · the stored contribution order defines this sequence.",
|
||||
));
|
||||
if !conversion.is_empty() {
|
||||
out = out.push(action(
|
||||
"Convert ordered path to explicit links",
|
||||
Message::Command(unique_batch(p, conversion)),
|
||||
));
|
||||
}
|
||||
(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height)
|
||||
.into(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(canvas)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(200., 470.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|size| event(Event::Viewport(size)))
|
||||
.on_resize(|size| event(Event::Viewport(size))),
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(target),
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
|
||||
{
|
||||
out = out.push(action(
|
||||
&format!("Open source: {} →", p.named(*target)),
|
||||
Message::Select(*target),
|
||||
));
|
||||
}
|
||||
for r in sequence_links(p, owner) {
|
||||
let guard = match &r.body {
|
||||
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
out = out.push(
|
||||
action(
|
||||
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
|
||||
Message::Select(r.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
|
||||
p.relations
|
||||
.values()
|
||||
@@ -942,6 +709,7 @@ struct GraphNode {
|
||||
source: Option<Id>,
|
||||
preview: Vec<(Id, String)>,
|
||||
preview_links: Vec<(Id, Id)>,
|
||||
preview_total: usize,
|
||||
}
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
@@ -951,15 +719,21 @@ struct GraphEdge {
|
||||
points: Vec<Point>,
|
||||
label: Rectangle,
|
||||
}
|
||||
struct Graph {
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
}
|
||||
impl Graph {
|
||||
fn new(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
impl<'a> Graph<'a> {
|
||||
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
|
||||
Self::expanded(p, owner, selected, &BTreeSet::new())
|
||||
}
|
||||
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
@@ -1000,17 +774,17 @@ impl Graph {
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut nodes = vec![];
|
||||
let mut size: Size = Size::new(640., 220.);
|
||||
for c in choices {
|
||||
let e = &p.elements[&c.id];
|
||||
let rank = ranks[&c.id];
|
||||
let slot = rows.entry(rank).or_default();
|
||||
let slot = rows.entry(rank).or_insert(24.);
|
||||
let mut node = GraphNode {
|
||||
id: e.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
|
||||
Point::new(24. + rank as f32 * 420., *slot),
|
||||
Size::new(270., 104.),
|
||||
),
|
||||
title: e.name.clone(),
|
||||
@@ -1019,8 +793,8 @@ impl Graph {
|
||||
source: None,
|
||||
preview: vec![],
|
||||
preview_links: vec![],
|
||||
preview_total: 0,
|
||||
};
|
||||
*slot += 1;
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
||||
match body {
|
||||
OccurrenceBody::Action { action, .. } => {
|
||||
@@ -1051,11 +825,19 @@ impl Graph {
|
||||
.map(|id| format!("ref · {}", p.named(id)))
|
||||
.unwrap_or("Choose source in Properties".into());
|
||||
if let Some(source) = sequence {
|
||||
node.preview = contributions(p, *source)
|
||||
let contributions = contributions(p, *source);
|
||||
node.preview_total = contributions.len();
|
||||
node.preview = contributions
|
||||
.into_iter()
|
||||
.take(4)
|
||||
.take(if expanded.contains(&e.id) {
|
||||
usize::MAX
|
||||
} else {
|
||||
4
|
||||
})
|
||||
.map(|c| (c.id, p.named(c.id).to_owned()))
|
||||
.collect();
|
||||
node.rect.height =
|
||||
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
|
||||
let preview_ids: BTreeSet<_> =
|
||||
node.preview.iter().map(|(id, _)| *id).collect();
|
||||
node.preview_links = sequence_links(p, *source)
|
||||
@@ -1071,11 +853,13 @@ impl Graph {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
*slot += node.rect.height + 36.;
|
||||
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
|
||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||
nodes.push(node);
|
||||
}
|
||||
let mut edges = vec![];
|
||||
let mut routed_pairs = BTreeSet::new();
|
||||
for (i, r) in links.into_iter().enumerate() {
|
||||
let a = nodes
|
||||
.iter()
|
||||
@@ -1089,11 +873,14 @@ impl Graph {
|
||||
.rect;
|
||||
let start = Point::new(a.x + a.width, a.center_y());
|
||||
let end = Point::new(b.x, b.center_y());
|
||||
let points = if end.x > start.x {
|
||||
// Parallel records keep distinct visible paths and guard hit targets.
|
||||
// Their semantic endpoints and IDs remain unchanged.
|
||||
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
|
||||
let points = if forward {
|
||||
let mid = (start.x + end.x) / 2.;
|
||||
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
|
||||
} else {
|
||||
let below = size.height + 24. + i as f32 * 30.;
|
||||
let below = size.height + 24. + i as f32 * 42.;
|
||||
vec![
|
||||
start,
|
||||
Point::new(start.x + 18., start.y),
|
||||
@@ -1107,7 +894,7 @@ impl Graph {
|
||||
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let label_at = if end.x > start.x {
|
||||
let label_at = if forward {
|
||||
Point::new(end.x - 138., end.y - 43.)
|
||||
} else {
|
||||
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
||||
@@ -1128,6 +915,9 @@ impl Graph {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
size,
|
||||
@@ -1204,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
|
||||
Stroke::default().with_color(color).with_width(width),
|
||||
);
|
||||
}
|
||||
impl canvas::Program<Message> for Graph {
|
||||
type State = ();
|
||||
impl canvas::Program<Message> for Graph<'_> {
|
||||
type State = Input;
|
||||
fn update(
|
||||
&self,
|
||||
_: &mut (),
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && let Some(point) = cursor.position_in(bounds)
|
||||
{
|
||||
let point = Point::new(point.x / self.scale, point.y / self.scale);
|
||||
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
|
||||
let id = if point.y > n.rect.y + 75. {
|
||||
n.source.unwrap_or(n.id)
|
||||
} else {
|
||||
n.id
|
||||
};
|
||||
return Some(Action::publish(Message::Select(id)).and_capture());
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points
|
||||
.windows(2)
|
||||
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
|
||||
}) {
|
||||
return Some(Action::publish(Message::Select(e.id)).and_capture());
|
||||
}
|
||||
}
|
||||
None
|
||||
self.input(state, event, bounds, cursor)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
state: &Input,
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
@@ -1327,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
"Open source →".into(),
|
||||
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
@@ -1400,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.overlay(state, &mut f, cursor, bounds);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_: &(),
|
||||
_: &Input,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
@@ -1478,6 +1246,9 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool: Tool::Branch,
|
||||
name: String::new(),
|
||||
@@ -1666,7 +1437,7 @@ mod tests {
|
||||
let command = branch(owner, a, b, before, after).command(&p).unwrap();
|
||||
session.execute(command).unwrap();
|
||||
let result = session.project();
|
||||
assert!(!result.relations.contains_key(&old_link));
|
||||
assert!(result.relations.contains_key(&old_link));
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
assert_eq!(sequence_links(result, owner).len(), 6);
|
||||
assert_eq!(
|
||||
@@ -1777,10 +1548,26 @@ mod tests {
|
||||
draft: Some(branch(owner, a, b, before, after)),
|
||||
..State::default()
|
||||
};
|
||||
state.update(&p, Event::Guard(String::new())).unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: Id::nil(),
|
||||
label: "Missing".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.update(&p, Event::Submit).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: a,
|
||||
label: "Work".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_some());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
@@ -1836,9 +1623,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(486., 460.)))
|
||||
.unwrap();
|
||||
state.viewport(Size::new(486., 460.));
|
||||
state.zoom(None);
|
||||
graph.scale = state.scale(graph.size, 500.);
|
||||
assert!(graph.size.width * graph.scale <= 462.1);
|
||||
assert!(graph.size.height * graph.scale <= 450.1);
|
||||
@@ -1849,7 +1635,7 @@ mod tests {
|
||||
);
|
||||
let result = canvas::Program::update(
|
||||
&graph,
|
||||
&mut (),
|
||||
&mut Input::default(),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
||||
mouse::Cursor::Available(click),
|
||||
@@ -1858,12 +1644,10 @@ mod tests {
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
||||
state.update(&p, Event::Zoom(1.)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(800., 460.)))
|
||||
.unwrap();
|
||||
state.zoom(Some(1.));
|
||||
state.viewport(Size::new(800., 460.));
|
||||
assert_eq!(state.scale(graph.size, 500.), 1.);
|
||||
state.update(&p, Event::Fit).unwrap();
|
||||
state.zoom(None);
|
||||
assert!(state.scale(graph.size, 500.) < 1.);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Local pointer state and ordinary command proposals for the derived process graph.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Insertion {
|
||||
pub link: Option<Id>,
|
||||
pub before: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CanvasTool {
|
||||
#[default]
|
||||
Select,
|
||||
Add(Tool, ControlKind),
|
||||
Connect,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum End {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Scrolled(Context, scenario::ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
AppendExchange(Context),
|
||||
Begin {
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
},
|
||||
Connect {
|
||||
context: Context,
|
||||
source: Id,
|
||||
target: Id,
|
||||
},
|
||||
Reconnect {
|
||||
context: Context,
|
||||
link: Id,
|
||||
end: End,
|
||||
target: Id,
|
||||
},
|
||||
Reorder {
|
||||
context: Context,
|
||||
occurrence: Id,
|
||||
before: Id,
|
||||
},
|
||||
Expand(Context, Id),
|
||||
Open(Context, Id),
|
||||
Back(Context),
|
||||
Zoom(Context, Option<f32>),
|
||||
Viewport(Context, Size),
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(c, ..)
|
||||
| Self::Choose(c, ..)
|
||||
| Self::Select(c, ..)
|
||||
| Self::Expand(c, ..)
|
||||
| Self::Open(c, ..)
|
||||
| Self::Back(c)
|
||||
| Self::AppendExchange(c)
|
||||
| Self::Zoom(c, ..)
|
||||
| Self::Viewport(c, ..) => *c,
|
||||
Self::Begin { context, .. }
|
||||
| Self::Connect { context, .. }
|
||||
| Self::Reconnect { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.identity != Some((document, owner)) {
|
||||
self.identity = Some((document, owner));
|
||||
self.tool = CanvasTool::Select;
|
||||
self.zoom = Some(1.);
|
||||
self.fit = false;
|
||||
self.viewport = None;
|
||||
self.expanded.clear();
|
||||
self.scroll_offsets = [iced::Vector::ZERO; 2];
|
||||
}
|
||||
}
|
||||
pub(crate) fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
scenario::ScrollArea::Canvas => 0,
|
||||
scenario::ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
pub(crate) fn choose(&mut self, tool: CanvasTool) {
|
||||
// Choosing how to edit must retain the user's current view.
|
||||
self.tool = tool;
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, value: Option<f32>) {
|
||||
self.fit = value.is_none();
|
||||
self.zoom = value.map(|v| v.clamp(0.5, 2.));
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
let changed = self.viewport != Some(size);
|
||||
self.viewport = Some(size);
|
||||
changed
|
||||
}
|
||||
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
|
||||
if !is_contribution(p, owner, id)
|
||||
|| !matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.expanded.remove(&id) {
|
||||
self.expanded.insert(id);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
|
||||
self.return_to.push((owner, occurrence));
|
||||
}
|
||||
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
|
||||
while let Some((owner, occurrence)) = self.return_to.pop() {
|
||||
if is_contribution(p, owner, occurrence) {
|
||||
return Some((owner, occurrence));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
let Command::Batch(commands) = command else {
|
||||
return None;
|
||||
};
|
||||
commands.iter().find_map(|c| match c {
|
||||
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub(crate) fn begin(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
context: Context,
|
||||
tool: Tool,
|
||||
control: ControlKind,
|
||||
insertion: Insertion,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !matches!(
|
||||
tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
|
||||
) {
|
||||
return Err("Choose a contribution tool.".into());
|
||||
}
|
||||
if !legacy_order(p, context.owner).is_empty() {
|
||||
return Err("Convert the ordered path to explicit links before drawing.".into());
|
||||
}
|
||||
let link = insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, context.owner, r.source())
|
||||
&& is_contribution(p, context.owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")
|
||||
})
|
||||
.transpose()?;
|
||||
if insertion
|
||||
.before
|
||||
.is_some_and(|id| !is_contribution(p, context.owner, id))
|
||||
{
|
||||
return Err("Choose a presentation slot in this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(context.owner, tool, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.insertion = insertion;
|
||||
d.control = control;
|
||||
if let Some(r) = link {
|
||||
d.before = Some(r.source());
|
||||
d.after = Some(r.target());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
|
||||
let last = legacy_order(p, owner)
|
||||
.last()
|
||||
.copied()
|
||||
.ok_or("Choose an ordered exchange path to append to.")?;
|
||||
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
|
||||
self.draft.as_mut().expect("started form").fixed = true;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn connect(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Result<(), String> {
|
||||
if self.draft.is_some() {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
if !is_contribution(p, owner, source)
|
||||
|| !is_contribution(p, owner, target)
|
||||
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
|
||||
{
|
||||
return Err("Choose compatible contributions directly owned by this process.".into());
|
||||
}
|
||||
self.update(p, Event::Start(owner, Tool::Connect, None))?;
|
||||
let d = self.draft.as_mut().expect("started form");
|
||||
d.fixed = true;
|
||||
d.before = Some(source);
|
||||
d.after = Some(target);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
|
||||
if !is_contribution(p, owner, occurrence) {
|
||||
return None;
|
||||
}
|
||||
match p.elements[&occurrence].shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { action, .. },
|
||||
..
|
||||
} => *action,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
} => *exchange,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => *sequence,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn reconnect(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
end: End,
|
||||
endpoint: Id,
|
||||
) -> Result<Command, String> {
|
||||
let mut r = p
|
||||
.relations
|
||||
.get(&id)
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& is_contribution(p, owner, r.source())
|
||||
&& is_contribution(p, owner, r.target())
|
||||
})
|
||||
.ok_or("Choose a sequence link in this process.")?
|
||||
.clone();
|
||||
if !is_contribution(p, owner, endpoint) {
|
||||
return Err("Choose a local contribution endpoint.".into());
|
||||
}
|
||||
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
|
||||
match end {
|
||||
End::Source => *source = endpoint,
|
||||
End::Target => *target = endpoint,
|
||||
}
|
||||
}
|
||||
let command = Command::EditRelation(id, r);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
|
||||
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
|
||||
return Err("Reorder contributions in this process only.".into());
|
||||
}
|
||||
let graph = Graph::new(p, owner, id);
|
||||
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
|
||||
if position(id) != position(before) {
|
||||
return Err("Reorder within this column; links determine the path columns.".into());
|
||||
}
|
||||
let mut steps = p.elements[&owner].steps().unwrap().clone();
|
||||
if id != before {
|
||||
steps.retain(|v| *v != id);
|
||||
let index = steps
|
||||
.iter()
|
||||
.position(|v| *v == before)
|
||||
.ok_or("This slot no longer exists.")?;
|
||||
steps.insert(index, id);
|
||||
}
|
||||
let command = Command::SetSteps(owner, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Input {
|
||||
context: Option<Context>,
|
||||
tool: CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
bounds: Option<Rectangle>,
|
||||
source: Option<Id>,
|
||||
press: Option<Press>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Press {
|
||||
Connect {
|
||||
source: Id,
|
||||
first: bool,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reconnect {
|
||||
link: Id,
|
||||
end: End,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Reorder {
|
||||
occurrence: Id,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
},
|
||||
Add {
|
||||
insertion: Insertion,
|
||||
},
|
||||
}
|
||||
impl Input {
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn node_at(&self, point: Point) -> Option<Id> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|n| n.rect.contains(point))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
fn edge_at(&self, point: Point) -> Option<Id> {
|
||||
self.edges
|
||||
.iter()
|
||||
.find(|e| {
|
||||
(!e.guard.is_empty() && e.label.contains(point))
|
||||
|| e.points.windows(2).any(|v| {
|
||||
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|e| e.id)
|
||||
}
|
||||
fn handle(&self, id: Id) -> Option<Rectangle> {
|
||||
let n = self.nodes.iter().find(|n| n.id == id)?;
|
||||
Some(Rectangle::new(
|
||||
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
|
||||
Size::new(23., 24.),
|
||||
))
|
||||
}
|
||||
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
|
||||
let e = self.edges.iter().find(|e| e.id == self.selected)?;
|
||||
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
|
||||
.into_iter()
|
||||
.find(|(p, _)| p.distance(point) < 10. / self.scale)
|
||||
.map(|(_, end)| (e.id, end))
|
||||
}
|
||||
fn connectable(&self, source: Id, target: Id) -> bool {
|
||||
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
|
||||
}
|
||||
pub(super) fn input(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
(Some(c), t, e)
|
||||
});
|
||||
if state.context != context
|
||||
|| state.tool != tool
|
||||
|| state.bounds.is_some_and(|b| b != bounds)
|
||||
{
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
state.context = context;
|
||||
state.tool = tool;
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
)
|
||||
{
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(
|
||||
Press::Connect { start, moved, .. }
|
||||
| Press::Reconnect { start, moved, .. }
|
||||
| Press::Reorder { start, moved, .. },
|
||||
) = &mut state.press
|
||||
{
|
||||
*moved |= position.distance(*start) >= 3.;
|
||||
}
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(
|
||||
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
|
||||
) => {
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) => {
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
) => state
|
||||
.pointer
|
||||
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||
cursor
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let world = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
|
||||
let point = world?;
|
||||
let Some(context) = context else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture());
|
||||
};
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
let start = cursor.position()? - self.scroll_offset;
|
||||
match tool {
|
||||
CanvasTool::Add(..) => {
|
||||
state.press = Some(Press::Add {
|
||||
insertion: Insertion {
|
||||
link: self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none()),
|
||||
before: self.node_at(point),
|
||||
},
|
||||
});
|
||||
}
|
||||
CanvasTool::Connect => {
|
||||
let first = state.source.is_none();
|
||||
let source = state.source.or_else(|| self.node_at(point))?;
|
||||
state.source = Some(source);
|
||||
state.press = Some(Press::Connect {
|
||||
source,
|
||||
first,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
CanvasTool::Select => {
|
||||
if let Some((link, end)) = self.endpoint_at(point) {
|
||||
state.press = Some(Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else if self
|
||||
.handle(self.selected)
|
||||
.is_some_and(|r| r.contains(point))
|
||||
{
|
||||
state.press = Some(Press::Reorder {
|
||||
occurrence: self.selected,
|
||||
start,
|
||||
moved: false,
|
||||
});
|
||||
} else {
|
||||
return self
|
||||
.node_at(point)
|
||||
.or_else(|| self.edge_at(point))
|
||||
.and_then(|id| publish(CanvasEvent::Select(context, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
Press::Add { insertion } if world.is_some() => {
|
||||
if let CanvasTool::Add(tool, control) = tool {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Begin {
|
||||
context,
|
||||
tool,
|
||||
control,
|
||||
insertion,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Connect {
|
||||
source,
|
||||
first,
|
||||
moved,
|
||||
..
|
||||
} if moved || !first => {
|
||||
if let Some(target) = world
|
||||
.and_then(|p| self.node_at(p))
|
||||
.filter(|target| self.connectable(source, *target))
|
||||
{
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Connect {
|
||||
context,
|
||||
source,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reconnect {
|
||||
link,
|
||||
end,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(target) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reconnect {
|
||||
context,
|
||||
link,
|
||||
end,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
Press::Reorder {
|
||||
occurrence,
|
||||
moved: true,
|
||||
..
|
||||
} => {
|
||||
if let Some(before) = world.and_then(|p| self.node_at(p)) {
|
||||
state.cancel();
|
||||
return publish(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence,
|
||||
before,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn overlay(
|
||||
&self,
|
||||
state: &Input,
|
||||
f: &mut Frame,
|
||||
cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let Some((context, tool, editable)) = self.interaction else {
|
||||
return;
|
||||
};
|
||||
if !editable {
|
||||
return;
|
||||
}
|
||||
let point = state
|
||||
.pointer
|
||||
.map(|p| p + self.scroll_offset)
|
||||
.or_else(|| cursor.position())
|
||||
.filter(|p| bounds.contains(*p))
|
||||
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
|
||||
let live =
|
||||
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
|
||||
if live && let Some(source) = state.source {
|
||||
for n in &self.nodes {
|
||||
if n.id == source || self.connectable(source, n.id) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
|
||||
draw_arrow(
|
||||
f,
|
||||
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
|
||||
style::TEAL,
|
||||
2.,
|
||||
);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reconnect { link, end, .. }) = state.press
|
||||
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
|
||||
{
|
||||
for node in &self.nodes {
|
||||
let (source, target) = match end {
|
||||
End::Source => (node.id, edge.target),
|
||||
End::Target => (edge.source, node.id),
|
||||
};
|
||||
if self.connectable(source, target) {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(point) = point {
|
||||
let (from, to) = match end {
|
||||
End::Source => (point, *edge.points.last().unwrap()),
|
||||
End::Target => (edge.points[0], point),
|
||||
};
|
||||
draw_arrow(f, &[from, to], style::TEAL, 2.5);
|
||||
}
|
||||
}
|
||||
if live
|
||||
&& let Some(Press::Reorder { occurrence, .. }) = state.press
|
||||
&& let Some(point) = point
|
||||
&& let Some(target) = self.node_at(point)
|
||||
{
|
||||
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
|
||||
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
|
||||
if source.rect.x == target.rect.x {
|
||||
f.stroke(
|
||||
&Path::line(
|
||||
Point::new(target.rect.x, target.rect.y - 7.),
|
||||
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
|
||||
),
|
||||
Stroke::default().with_color(style::TEAL).with_width(3.),
|
||||
);
|
||||
draw_text(
|
||||
f,
|
||||
"Order before · paths unchanged".into(),
|
||||
Point::new(target.rect.x, target.rect.y - 24.),
|
||||
12.,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(r) = self.handle(self.selected) {
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
|
||||
for p in [e.points[0], *e.points.last().unwrap()] {
|
||||
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let CanvasTool::Add(..) = tool
|
||||
&& let Some(point) = point
|
||||
{
|
||||
let at = self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none());
|
||||
let caption = if let Some(id) = at {
|
||||
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
|
||||
draw_arrow(f, &e.points, style::TEAL, 3.);
|
||||
}
|
||||
"Insert on this path · preserve its guard"
|
||||
} else if self.node_at(point).is_some() {
|
||||
"Add before this contribution · unconnected"
|
||||
} else {
|
||||
"Append contribution · unconnected"
|
||||
};
|
||||
draw_text(
|
||||
f,
|
||||
caption.into(),
|
||||
Point::new(point.x + 12., point.y + 16.),
|
||||
12. / self.scale,
|
||||
style::TEAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn selection_offset(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
id: Id,
|
||||
) -> Option<scrollable::AbsoluteOffset> {
|
||||
let graph = Graph::expanded(p, owner, id, &self.expanded);
|
||||
let scale = self.scale(graph.size, 500.);
|
||||
let rect = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.map(|n| n.rect)
|
||||
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
|
||||
Some(scrollable::AbsoluteOffset {
|
||||
x: (rect.x * scale - 12.).max(0.),
|
||||
y: (rect.y * scale - 12.).max(0.),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,493 @@
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
pub(super) fn picker<'a>(
|
||||
mut choices: Vec<Choice>,
|
||||
d: &'a Draft,
|
||||
index: usize,
|
||||
value: Option<Id>,
|
||||
placeholder: &'a str,
|
||||
change: fn(Choice) -> Event,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = value
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.cloned();
|
||||
let browser = &d.browsers[index];
|
||||
let query = browser.search.trim().to_lowercase();
|
||||
choices.retain(|c| c.label.to_lowercase().contains(&query));
|
||||
let range = choice_page(choices.len(), browser.page);
|
||||
let page = range.start / 24;
|
||||
let mut out = column![
|
||||
text_input("Search compatible choices…", &browser.search)
|
||||
.on_input(move |v| event(Event::Search(index, v)))
|
||||
.padding(7)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(selected) = selected
|
||||
.as_ref()
|
||||
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
|
||||
{
|
||||
out = out.push(label(format!("Selected: {}", selected.label)));
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
button("Previous")
|
||||
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
|
||||
text(format!(
|
||||
"{} choices · {}/{}",
|
||||
choices.len(),
|
||||
page + 1,
|
||||
choices.len().div_ceil(24).max(1)
|
||||
))
|
||||
.size(11),
|
||||
button("Next").on_press_maybe(
|
||||
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
out.push(
|
||||
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
|
||||
.placeholder(placeholder)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
|
||||
let start = requested.min(len.saturating_sub(1) / 24) * 24;
|
||||
start..(start + 24).min(len)
|
||||
}
|
||||
pub(super) fn view<'a>(
|
||||
app: &'a App,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let context = app.topology_context().expect("visible process context");
|
||||
let state = &app.behavior.topology;
|
||||
let pending = state.draft.is_some();
|
||||
let explicit =
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
// Keep the canvas at the same sibling position when its palette becomes a
|
||||
// pending-form notice, so Iced retains the scrollable's local offsets.
|
||||
let mut controls = Column::new().spacing(14);
|
||||
if !pending && explicit {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
button("Select / reorder")
|
||||
.style(if state.tool == CanvasTool::Select {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Select)),
|
||||
button("Work")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||||
button("Exchange")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||||
button("Reuse")
|
||||
.style(
|
||||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
}
|
||||
)
|
||||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.push(
|
||||
row![
|
||||
pick_list(
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop
|
||||
]
|
||||
.into_iter()
|
||||
.map(ControlChoice)
|
||||
.collect::<Vec<_>>(),
|
||||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||||
Some(ControlChoice(kind))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
CanvasTool::Add(Tool::Control, c.0)
|
||||
))
|
||||
)
|
||||
.placeholder("Draw control…")
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
button("Sequence link")
|
||||
.style(if state.tool == CanvasTool::Connect {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(choose(CanvasTool::Connect)),
|
||||
button("Two paths…").on_press(event(Event::Start(
|
||||
owner,
|
||||
Tool::Branch,
|
||||
selected
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
out = out.push(controls);
|
||||
if explicit {
|
||||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||||
graph.interaction = Some((
|
||||
context,
|
||||
state.tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
graph.scroll_offset = state.scroll_offset();
|
||||
let scale = graph.scale;
|
||||
out = out.push(
|
||||
row![
|
||||
button("Fit overview")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale / 1.25)
|
||||
))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(scale * 1.25)
|
||||
))),
|
||||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(1.)
|
||||
)))
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
if graph.nodes.is_empty() {
|
||||
out = out.push(label(
|
||||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||||
));
|
||||
}
|
||||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||||
let canvas = scrollable(
|
||||
canvas::Canvas::new(graph)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.id("process-canvas")
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(if pending {
|
||||
(app.window_size.height - 510.).clamp(160., 240.)
|
||||
} else {
|
||||
(app.window_size.height - 420.).clamp(190., 420.)
|
||||
});
|
||||
out = out.push(
|
||||
iced::widget::sensor(
|
||||
container(super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Canvas,
|
||||
canvas,
|
||||
))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||||
);
|
||||
} else {
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
if !pending {
|
||||
out = out.push(
|
||||
button("Append exchange…")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
|
||||
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
|
||||
}
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
out = out.push(
|
||||
button("Convert ordered path to explicit links")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
} else {
|
||||
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
|
||||
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
|
||||
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
|
||||
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
|
||||
}
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
}
|
||||
if let Some(id) = selected {
|
||||
let mut actions =
|
||||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||||
.spacing(6);
|
||||
if let Some(target) = direct::referenced(p, owner, id) {
|
||||
actions = actions.push(
|
||||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||||
);
|
||||
if matches!(
|
||||
p.elements[&id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
) {
|
||||
actions = actions.push(
|
||||
button(if state.expanded.contains(&id) {
|
||||
"Collapse"
|
||||
} else {
|
||||
"Expand"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(actions);
|
||||
} else if sequence_links(p, owner)
|
||||
.iter()
|
||||
.any(|r| r.id == app.selected)
|
||||
{
|
||||
out = out.push(
|
||||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||||
);
|
||||
}
|
||||
if !state.return_to.is_empty() {
|
||||
out = out.push(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
actions,
|
||||
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
);
|
||||
}
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,11 @@ pub struct FieldPathKey {
|
||||
pub struct FieldPathChoice {
|
||||
pub key: FieldPathKey,
|
||||
pub label: String,
|
||||
pub picker_label: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldPathChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
f.write_str(&self.picker_label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +50,7 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ");
|
||||
.collect::<Vec<_>>();
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
@@ -63,54 +63,97 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let terminal = qualified
|
||||
.last()
|
||||
.expect("A field path always has a terminal");
|
||||
let owner_path = qualified[..qualified.len() - 1].join(" → ");
|
||||
let picker_label = format!(
|
||||
"{terminal}{direction} — {}{}",
|
||||
project.named(entry.item),
|
||||
if owner_path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" / {owner_path}")
|
||||
},
|
||||
);
|
||||
FieldPathChoice {
|
||||
key: FieldPathKey {
|
||||
path: entry.path,
|
||||
field: entry.field,
|
||||
},
|
||||
label: format!("{} / {qualified}{direction}", project.named(entry.item)),
|
||||
label: format!(
|
||||
"{} / {}{direction}",
|
||||
project.named(entry.item),
|
||||
qualified.join(" → ")
|
||||
),
|
||||
picker_label,
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded picker must expose its distinguishing field before a long path.
|
||||
// Keep the full address separately for the wrapping detail text.
|
||||
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
|
||||
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn choices(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
Ok(data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect())
|
||||
Ok(sorted_choices(
|
||||
data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
exchange
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
data::effective_fields(project, *item)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
let SharedBody::DataField { direction, .. } = field.shared()? else {
|
||||
return None;
|
||||
};
|
||||
Some(data::CarriedFieldPath {
|
||||
item: *item,
|
||||
path: vec![],
|
||||
field: field.id,
|
||||
direction: *direction,
|
||||
sorted_choices(
|
||||
exchange
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
data::effective_fields(project, *item)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
let SharedBody::DataField { direction, .. } = field.shared()? else {
|
||||
return None;
|
||||
};
|
||||
Some(data::CarriedFieldPath {
|
||||
item: *item,
|
||||
path: vec![],
|
||||
field: field.id,
|
||||
direction: *direction,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect()
|
||||
})
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Browse one record at a time, including explicitly chosen recursive hops.
|
||||
pub fn at_scope(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
scope: Option<&FieldPathKey>,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
match scope {
|
||||
Some(key) => children(project, exchange, phase, key),
|
||||
None => Ok(roots(project, exchange, phase)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||
@@ -166,20 +209,22 @@ pub fn children(
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
Ok(data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
named_choice(
|
||||
project,
|
||||
data::CarriedFieldPath {
|
||||
item: entry.item,
|
||||
path: path.clone(),
|
||||
field: field.id,
|
||||
direction: entry.direction,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
Ok(sorted_choices(
|
||||
data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
named_choice(
|
||||
project,
|
||||
data::CarriedFieldPath {
|
||||
item: entry.item,
|
||||
path: path.clone(),
|
||||
field: field.id,
|
||||
direction: entry.direction,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -482,4 +527,33 @@ pub(crate) mod tests {
|
||||
.contains("128")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let context = FieldPathKey {
|
||||
path: vec![f.origin, next, next],
|
||||
field: next,
|
||||
};
|
||||
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
|
||||
let leaf = visible
|
||||
.iter()
|
||||
.find(|choice| choice.key.field == f.leaf)
|
||||
.unwrap();
|
||||
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
|
||||
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
|
||||
assert!(
|
||||
visible.windows(2).all(
|
||||
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
visible[0].to_string().chars().take(24).collect::<String>(),
|
||||
visible[1].to_string().chars().take(24).collect::<String>()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+169
-35
@@ -32,6 +32,12 @@ pub struct Input {
|
||||
pub raw: String,
|
||||
pub editor: text_editor::Content,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
pub original: Id,
|
||||
@@ -39,7 +45,9 @@ pub struct Draft {
|
||||
baseline: Value,
|
||||
pub inputs: BTreeMap<String, Input>,
|
||||
pub exact_strings: bool,
|
||||
context: Option<Value>,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -76,12 +84,16 @@ impl Draft {
|
||||
value,
|
||||
inputs: BTreeMap::new(),
|
||||
exact_strings: false,
|
||||
context: None,
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
||||
match value {
|
||||
@@ -132,6 +144,16 @@ impl Draft {
|
||||
pub fn materialize(&self) -> Result<Value, String> {
|
||||
self.materialize_inputs(|_| true)
|
||||
}
|
||||
/// Read-only context for dependent controls. Workflow proposals never replace
|
||||
/// the authored value or its input buffers and are not used by command().
|
||||
pub fn context_value(&self) -> Value {
|
||||
self.context
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
|
||||
}
|
||||
pub fn set_context(&mut self, context: Option<Value>) {
|
||||
self.context = context;
|
||||
}
|
||||
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
|
||||
self.materialize_inputs(|path| {
|
||||
@@ -216,6 +238,97 @@ impl Draft {
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
|
||||
/// including an invalid value which this replacement deliberately removes.
|
||||
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
*self
|
||||
.value
|
||||
.pointer_mut(path)
|
||||
.ok_or("Property no longer exists.")? = value;
|
||||
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
|
||||
let previous = std::mem::take(&mut self.inputs);
|
||||
self.rebuild();
|
||||
for (path, input) in previous {
|
||||
if let Some(path) = new_path(&path)
|
||||
&& self.inputs.contains_key(&path)
|
||||
{
|
||||
self.inputs.insert(path, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn array_scoped(
|
||||
&mut self,
|
||||
path: &str,
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.value.clone();
|
||||
let count = candidate
|
||||
.pointer(path)
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("List no longer exists.")?
|
||||
.len();
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild_retaining(|old| {
|
||||
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
|
||||
return Some(old.to_owned());
|
||||
};
|
||||
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
|
||||
let position = number.parse::<usize>().ok()?;
|
||||
let position = match index {
|
||||
Some(index) if direction == 0 && position == index => return None,
|
||||
Some(index) if direction == 0 && position > index => position - 1,
|
||||
Some(index) if direction != 0 => {
|
||||
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
|
||||
if position == index {
|
||||
target
|
||||
} else if position == target {
|
||||
index
|
||||
} else {
|
||||
position
|
||||
}
|
||||
}
|
||||
_ => position,
|
||||
};
|
||||
Some(if rest.is_empty() {
|
||||
format!("{path}/{position}")
|
||||
} else {
|
||||
format!("{path}/{position}/{rest}")
|
||||
})
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
self.value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.rebuild_retaining(|path| Some(path.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path_scoped(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let row = self
|
||||
.value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
self.rebuild_retaining(|old| {
|
||||
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
|
||||
.then(|| old.to_owned())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn array(
|
||||
&mut self,
|
||||
path: &str,
|
||||
@@ -223,36 +336,7 @@ impl Draft {
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
let items = candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?;
|
||||
match index {
|
||||
None => items.push(if path.ends_with("literals") {
|
||||
json!("New value")
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"path": [], "field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
Some(i) if i < items.len() => {
|
||||
if direction == 0 {
|
||||
items.remove(i);
|
||||
} else {
|
||||
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
|
||||
items.swap(i, target);
|
||||
}
|
||||
}
|
||||
_ => return Err("List item no longer exists.".into()),
|
||||
}
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
@@ -354,6 +438,51 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
|
||||
fn within(path: &str, root: &str) -> bool {
|
||||
path == root
|
||||
|| path
|
||||
.strip_prefix(root)
|
||||
.is_some_and(|tail| tail.starts_with('/'))
|
||||
}
|
||||
fn edit_array(
|
||||
value: &mut Value,
|
||||
path: &str,
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
let items = value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?;
|
||||
match index {
|
||||
None => items.push(if path.ends_with("literals") {
|
||||
json!("New value")
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"path": [], "field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
Some(i) if i < items.len() => {
|
||||
if direction == 0 {
|
||||
items.remove(i);
|
||||
} else {
|
||||
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
|
||||
items.swap(i, target);
|
||||
}
|
||||
}
|
||||
_ => return Err("List item no longer exists.".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn friendly(name: &str) -> String {
|
||||
match name {
|
||||
"id" => "UUID identity".into(),
|
||||
@@ -568,7 +697,7 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
|
||||
}
|
||||
|
||||
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let value = draft.context_value();
|
||||
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
|
||||
let owner = value
|
||||
.get("element")
|
||||
@@ -724,10 +853,15 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
..
|
||||
},
|
||||
..
|
||||
} => project
|
||||
.relations
|
||||
.get(id)
|
||||
.and_then(|r| crate::data_values::choices(project, r, message_kind).ok()),
|
||||
} => project.relations.get(id).and_then(|r| {
|
||||
let scope = address_path.split_last().map(|(&field, path)| {
|
||||
crate::data_values::FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
}
|
||||
});
|
||||
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::*;
|
||||
use iced::widget::column;
|
||||
|
||||
fn element(draft: &Draft, project: &Project) -> Option<Element> {
|
||||
serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone()))
|
||||
serde_json::from_value(draft.context_value())
|
||||
.ok()
|
||||
.or_else(|| project.elements.get(&draft.original).cloned())
|
||||
}
|
||||
@@ -113,9 +113,9 @@ pub(super) fn property<'a>(
|
||||
let minimum_path = path.replace("maximum", "minimum");
|
||||
json!(
|
||||
draft
|
||||
.materialize()
|
||||
.ok()
|
||||
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
|
||||
.context_value()
|
||||
.pointer(&minimum_path)
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1)
|
||||
.max(1)
|
||||
)
|
||||
@@ -311,7 +311,7 @@ fn message_value<'a>(
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
) -> View<'a, Message> {
|
||||
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let edited = draft.context_value();
|
||||
let row = edited.pointer(path).unwrap_or(value).clone();
|
||||
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
|
||||
let owner = element(draft, project);
|
||||
@@ -330,97 +330,152 @@ fn message_value<'a>(
|
||||
} => project.relations.get(id).map(|r| (r, message_kind)),
|
||||
_ => None,
|
||||
});
|
||||
let choices = context
|
||||
.map(|(r, phase)| crate::data_values::choices(project, r, phase))
|
||||
.unwrap_or_else(|| Ok(vec![]));
|
||||
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
|
||||
match choices {
|
||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||
Ok(mut choices) => {
|
||||
let current_key = current.as_ref().map(|v| crate::data_values::FieldPathKey {
|
||||
path: v.path.clone(),
|
||||
field: v.field,
|
||||
});
|
||||
let mut selected = current_key
|
||||
let current_key = current
|
||||
.as_ref()
|
||||
.map(|value| crate::data_values::FieldPathKey {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
});
|
||||
let selected = context.and_then(|(exchange, phase)| {
|
||||
current_key
|
||||
.as_ref()
|
||||
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
|
||||
});
|
||||
let browser = draft
|
||||
.value_browsers
|
||||
.get(path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ValueBrowser {
|
||||
scope: selected
|
||||
.as_ref()
|
||||
.and_then(|key| choices.iter().find(|c| &c.key == key))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
context.and_then(|(r, phase)| {
|
||||
current_key
|
||||
.as_ref()
|
||||
.and_then(|key| crate::data_values::resolve(project, r, phase, key))
|
||||
})
|
||||
});
|
||||
if let Some(choice) = &selected
|
||||
&& !choices.iter().any(|c| c.key == choice.key)
|
||||
{
|
||||
choices.push(choice.clone());
|
||||
}
|
||||
if selected.is_none()
|
||||
&& let Some(key) = current_key.clone()
|
||||
&& !key.field.is_nil()
|
||||
{
|
||||
out = out.push(text("Current address needs review. Choose an explicit path to resolve a legacy, ambiguous or incompatible value.").size(11).color(style::MUTED));
|
||||
let retained = crate::data_values::FieldPathChoice {
|
||||
label: format!(
|
||||
"Current address · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| project.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
project.named(key.field)
|
||||
),
|
||||
key,
|
||||
};
|
||||
selected = Some(retained.clone());
|
||||
choices.push(retained);
|
||||
}
|
||||
let owned = path.to_owned();
|
||||
out = out.push(
|
||||
pick_list(choices, selected, move |choice| {
|
||||
Message::FieldValuePath(owned.clone(), choice.key)
|
||||
})
|
||||
.placeholder("Choose a carried field path…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
.and_then(|choice| crate::data_values::parent(&choice.key)),
|
||||
page: 0,
|
||||
});
|
||||
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
|
||||
if let Some(choice) = &selected {
|
||||
out = out.push(text(choice.label.clone()).size(11));
|
||||
} else if let Some(key) = ¤t_key
|
||||
&& !key.field.is_nil()
|
||||
{
|
||||
out = out.push(
|
||||
text(format!(
|
||||
"Current address needs review: {} / {}. Choose an explicit field.",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| project.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
project.named(key.field)
|
||||
))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
if let (Some((exchange, phase)), Some(current)) = (context, current) {
|
||||
let key = crate::data_values::FieldPathKey {
|
||||
path: current.path,
|
||||
field: current.field,
|
||||
};
|
||||
if crate::data_values::resolve(project, exchange, phase, &key).is_some() {
|
||||
if let Some(parent) = crate::data_values::parent(&key) {
|
||||
out = out.push(
|
||||
button(text("Up to enclosing field").size(11))
|
||||
if let Some((exchange, phase)) = context {
|
||||
let location = browser
|
||||
.scope
|
||||
.as_ref()
|
||||
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
|
||||
.map(|choice| choice.label)
|
||||
.unwrap_or_else(|| "Root carried fields".into());
|
||||
out = out.push(
|
||||
text(format!("Browse: {location}"))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
if let Some(scope) = &browser.scope {
|
||||
let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press(
|
||||
Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0)
|
||||
)]
|
||||
.spacing(4);
|
||||
if !scope.path.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button(text("Root fields").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldValuePath(path.into(), parent)),
|
||||
.on_press(Message::FieldValueBrowse(path.into(), None, 0)),
|
||||
);
|
||||
}
|
||||
match crate::data_values::children(project, exchange, phase, &key) {
|
||||
Ok(children) if !children.is_empty() => {
|
||||
let path = path.to_owned();
|
||||
out = out.push(navigation);
|
||||
}
|
||||
match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) {
|
||||
Ok(choices) => {
|
||||
let count = choices.len();
|
||||
let (visible, page, pages) = field_page(choices, browser.page);
|
||||
// Selection detail stays visible independently of browser location/page.
|
||||
let visible_selection = selected
|
||||
.clone()
|
||||
.filter(|choice| visible.iter().any(|c| c.key == choice.key));
|
||||
let owned = path.to_owned();
|
||||
out = out.push(
|
||||
pick_list(visible, visible_selection, move |choice| {
|
||||
Message::FieldValuePath(owned.clone(), choice.key)
|
||||
})
|
||||
.placeholder("Choose a field here…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
if pages > 1 {
|
||||
out = out.push(
|
||||
pick_list(
|
||||
children,
|
||||
None::<crate::data_values::FieldPathChoice>,
|
||||
move |choice| Message::FieldValuePath(path.clone(), choice.key),
|
||||
)
|
||||
.placeholder("Choose a field inside this record…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
row![
|
||||
button(text("Previous").size(11))
|
||||
.style(style::nav)
|
||||
.on_press_maybe((page > 0).then(|| Message::FieldValueBrowse(
|
||||
path.into(),
|
||||
browser.scope.clone(),
|
||||
page - 1
|
||||
))),
|
||||
text(format!("{} / {pages} · {count} fields", page + 1)).size(11),
|
||||
button(text("Next").size(11))
|
||||
.style(style::nav)
|
||||
.on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::FieldValueBrowse(
|
||||
path.into(),
|
||||
browser.scope.clone(),
|
||||
page + 1,
|
||||
)
|
||||
}))
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
} else if count == 0 {
|
||||
out = out.push(
|
||||
text("No compatible fields at this location.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||
_ => {}
|
||||
}
|
||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||
}
|
||||
if let Some(choice) = &selected
|
||||
&& browser.scope.as_ref() != Some(&choice.key)
|
||||
&& crate::data_values::children(project, exchange, phase, &choice.key)
|
||||
.is_ok_and(|children| !children.is_empty())
|
||||
{
|
||||
out = out.push(
|
||||
button(
|
||||
text(format!(
|
||||
"Browse fields in {} →",
|
||||
project.named(choice.key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldValueBrowse(
|
||||
path.into(),
|
||||
Some(choice.key.clone()),
|
||||
0,
|
||||
)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
out = out.push(
|
||||
text("Choose a message exchange to browse its carried fields.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
if let Some(expression) = value.get("expression") {
|
||||
out = out.push(super::property(
|
||||
@@ -435,10 +490,280 @@ fn message_value<'a>(
|
||||
out.into()
|
||||
}
|
||||
|
||||
const FIELDS_PER_PAGE: usize = 24;
|
||||
|
||||
fn field_page(
|
||||
choices: Vec<crate::data_values::FieldPathChoice>,
|
||||
requested: usize,
|
||||
) -> (Vec<crate::data_values::FieldPathChoice>, usize, usize) {
|
||||
let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1);
|
||||
let page = requested.min(pages - 1);
|
||||
(
|
||||
choices
|
||||
.into_iter()
|
||||
.skip(page * FIELDS_PER_PAGE)
|
||||
.take(FIELDS_PER_PAGE)
|
||||
.collect(),
|
||||
page,
|
||||
pages,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn semantic_state(app: &crate::app::App) -> &crate::semantic_review::State {
|
||||
let Some(crate::workflow::Workflow::Semantic(state)) = &app.workflow else {
|
||||
panic!()
|
||||
};
|
||||
state
|
||||
}
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
}
|
||||
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
|
||||
use crate::semantic_review::Event;
|
||||
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
|
||||
semantic_send(
|
||||
app,
|
||||
Event::Decide(FacetSelector::Field(facet), Some(decision)),
|
||||
);
|
||||
}
|
||||
fn default_buttons(draft: &Draft, project: &Project, path: &str) -> usize {
|
||||
use iced::advanced::widget::{Tree, tree::Tag};
|
||||
let button: View<'_, Message> = button(text("Button")).into();
|
||||
let tag = Tree::new(button.as_widget()).tag;
|
||||
let view = property(draft, project, draft.value.pointer(path).unwrap(), path).unwrap();
|
||||
fn count(tree: &Tree, tag: Tag) -> usize {
|
||||
usize::from(tree.tag == tag)
|
||||
+ tree
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| count(child, tag))
|
||||
.sum::<usize>()
|
||||
}
|
||||
count(&Tree::new(view.as_widget()), tag)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
|
||||
use crate::semantic_review::{Event, State};
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Target", None)
|
||||
.unwrap();
|
||||
let operation = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Operation base", None)
|
||||
.unwrap();
|
||||
let flow = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Flow base", None)
|
||||
.unwrap();
|
||||
for (id, next) in [
|
||||
(oa, ExchangeMechanism::Operation),
|
||||
(sa, ExchangeMechanism::Flow),
|
||||
(operation, ExchangeMechanism::Operation),
|
||||
(flow, ExchangeMechanism::Flow),
|
||||
] {
|
||||
let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&id).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*mechanism = next;
|
||||
}
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "item_trace".into(),
|
||||
name: "Review item".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let mut state = State::new(&p, oa).unwrap();
|
||||
let facet = FacetSelector::ExchangeItem(ExchangeItemFacet::Mechanism);
|
||||
let path = "/body/Sa/Shared/ExchangeItem/supertype";
|
||||
let raw = state.draft.as_ref().unwrap().value.clone();
|
||||
state.update(
|
||||
&p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
|
||||
assert!(choices.contains(&operation));
|
||||
assert!(!choices.contains(&flow));
|
||||
assert_eq!(state.draft.as_ref().unwrap().value, raw);
|
||||
state.update(&p, Event::Decide(facet, Some(SemanticDecision::KeepTarget)));
|
||||
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
|
||||
assert!(!choices.contains(&operation));
|
||||
assert!(choices.contains(&flow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
|
||||
use crate::semantic_review::Event;
|
||||
for reverse in [false, true] {
|
||||
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let source = (layer == Layer::Oa) != reverse;
|
||||
let record = p
|
||||
.add_element(Kind::DataType, layer, "Record", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let field = p
|
||||
.add_element(Kind::DataField, layer, "Payload", Some(record))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::DataField {
|
||||
type_ref: if source {
|
||||
TypeRef::Boolean
|
||||
} else {
|
||||
TypeRef::Integer
|
||||
},
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
pair.push(field);
|
||||
}
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "payload_trace".into(),
|
||||
name: "Payload review".into(),
|
||||
description: String::new(),
|
||||
oa: pair[0],
|
||||
sa: pair[1],
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let (source, target, layer) = if reverse {
|
||||
(pair[1], pair[0], "Oa")
|
||||
} else {
|
||||
(pair[0], pair[1], "Sa")
|
||||
};
|
||||
let root = format!("/body/{layer}/Shared/DataField");
|
||||
let default = format!("{root}/default");
|
||||
let type_path = format!("{root}/type_ref");
|
||||
let minimum = format!("{root}/minimum");
|
||||
let mut app = crate::app::App::with_project(p);
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::StartSemanticReview(source));
|
||||
// An unfinished candidate must not make the context fall back to
|
||||
// the old target type, or contaminate a later Keep decision.
|
||||
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::Candidate);
|
||||
semantic_send(&mut app, Event::Input(minimum.clone(), "-".into()));
|
||||
semantic_decide(&mut app, FieldFacet::Type, decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
semantic_send(
|
||||
&mut app,
|
||||
Event::Variant(type_path.clone(), json!("Boolean")),
|
||||
);
|
||||
}
|
||||
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
|
||||
assert!(!semantic_state(&app).can_apply());
|
||||
let draft = semantic_state(&app).draft.as_ref().unwrap();
|
||||
assert!(matches!(
|
||||
element(draft, app.project()).unwrap().shared(),
|
||||
Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Boolean,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(draft.inputs[&minimum].raw, "-");
|
||||
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::KeepTarget);
|
||||
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
|
||||
assert!(semantic_state(&app).can_apply());
|
||||
let draft = semantic_state(&app).draft.as_ref().unwrap();
|
||||
assert_eq!(default_buttons(draft, app.project(), &default), 2);
|
||||
// These are the same element/definition/literal helpers used by
|
||||
// the visible Set literal default and Add literal buttons.
|
||||
let owner = element(draft, app.project()).unwrap();
|
||||
let Some(SharedBody::DataField { type_ref, .. }) = owner.shared() else {
|
||||
panic!()
|
||||
};
|
||||
let initial = literal(&definition(app.project(), type_ref).unwrap()).unwrap();
|
||||
assert_eq!(initial, json!({"Boolean": false}));
|
||||
if decision == SemanticDecision::TakeSource {
|
||||
assert_eq!(draft.value.pointer(&type_path), Some(&json!("Integer")));
|
||||
}
|
||||
// Reverting the type changes the controls without changing its
|
||||
// retained candidate. Storage constraints likewise read context.
|
||||
semantic_decide(&mut app, FieldFacet::Type, SemanticDecision::KeepTarget);
|
||||
assert!(matches!(
|
||||
element(semantic_state(&app).draft.as_ref().unwrap(), app.project())
|
||||
.unwrap()
|
||||
.shared(),
|
||||
Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Integer,
|
||||
..
|
||||
})
|
||||
));
|
||||
semantic_decide(&mut app, FieldFacet::Type, decision);
|
||||
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::Candidate);
|
||||
semantic_send(
|
||||
&mut app,
|
||||
Event::Variant(format!("{root}/storage"), json!("Reference")),
|
||||
);
|
||||
assert_eq!(
|
||||
default_buttons(
|
||||
semantic_state(&app).draft.as_ref().unwrap(),
|
||||
app.project(),
|
||||
&default
|
||||
),
|
||||
0
|
||||
);
|
||||
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::KeepTarget);
|
||||
assert_eq!(
|
||||
default_buttons(
|
||||
semantic_state(&app).draft.as_ref().unwrap(),
|
||||
app.project(),
|
||||
&default
|
||||
),
|
||||
2
|
||||
);
|
||||
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
|
||||
semantic_send(&mut app, Event::Shape(default, json!([initial])));
|
||||
assert!(
|
||||
semantic_state(&app).can_apply(),
|
||||
"{:?}",
|
||||
semantic_state(&app).error
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert!(
|
||||
matches!(app.project().elements[&target].shared(), Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Boolean, default: Some(values), minimum: 0, storage: FieldStorage::Value, ..
|
||||
}) if values == &vec![DataValue::Boolean(false)])
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
|
||||
let (mut f, next) = crate::data_values::tests::recursive_payload();
|
||||
@@ -490,6 +815,25 @@ mod tests {
|
||||
format!("{path}/expression"),
|
||||
"newly typed".into(),
|
||||
));
|
||||
let before_browse = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
for (scope, page) in [
|
||||
(None, 0),
|
||||
(
|
||||
Some(crate::data_values::FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
}),
|
||||
999,
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page));
|
||||
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
before_browse
|
||||
);
|
||||
assert_eq!(app.project(), &f.project);
|
||||
}
|
||||
let _ = app.update(Message::FieldValuePath(
|
||||
path.into(),
|
||||
crate::data_values::FieldPathKey {
|
||||
@@ -553,6 +897,43 @@ mod tests {
|
||||
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_browser_pages_every_direct_field_without_flattening_other_records() {
|
||||
let f = crate::data_values::tests::wide_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let roots =
|
||||
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap();
|
||||
assert!(roots.len() > FIELDS_PER_PAGE);
|
||||
assert!(roots.iter().all(|choice| choice.key.path.is_empty()));
|
||||
let scope = crate::data_values::FieldPathKey {
|
||||
path: vec![],
|
||||
field: f.origin,
|
||||
};
|
||||
let children =
|
||||
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope))
|
||||
.unwrap();
|
||||
assert!(children.len() > FIELDS_PER_PAGE);
|
||||
assert!(
|
||||
children
|
||||
.iter()
|
||||
.all(|choice| choice.key.path == vec![f.origin])
|
||||
);
|
||||
for choices in [roots, children] {
|
||||
let (_, _, pages) = field_page(choices.clone(), 0);
|
||||
let visited: Vec<_> = (0..pages)
|
||||
.flat_map(|page| {
|
||||
let (visible, actual, _) = field_page(choices.clone(), page);
|
||||
assert_eq!(actual, page);
|
||||
assert!(visible.len() <= FIELDS_PER_PAGE);
|
||||
visible
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(visited, choices);
|
||||
assert_eq!(field_page(choices, usize::MAX).1, pages - 1);
|
||||
}
|
||||
assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(super) fn variants(
|
||||
_value: &Value,
|
||||
) -> Option<Vec<(String, Value)>> {
|
||||
if path.ends_with("/Port/contract") {
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let current = draft.context_value();
|
||||
let sa = current.pointer("/body/Sa").is_some();
|
||||
if !sa {
|
||||
return Some(vec![]);
|
||||
@@ -80,7 +80,7 @@ pub(super) fn variants(
|
||||
);
|
||||
}
|
||||
if path.ends_with("/Port/contract/Component/orientation") {
|
||||
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let current = draft.context_value();
|
||||
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
|
||||
let orientations: &[&str] =
|
||||
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Topic {
|
||||
Self::Source => "Textual editing & Git",
|
||||
Self::Recovery => "Checks & error recovery",
|
||||
Self::Files => "Files & keyboard shortcuts",
|
||||
Self::Server => "REST API & automation",
|
||||
Self::Server => "Hosted models & REST API",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,16 +751,42 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Server => {
|
||||
const {
|
||||
Article {
|
||||
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
|
||||
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
|
||||
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
|
||||
sections: &[
|
||||
example(
|
||||
"Start a local service",
|
||||
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
|
||||
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
|
||||
"From the repository root, run the command below in a terminal and leave it running. The default address is loopback 127.0.0.1:8787, with platform-local storage. docs/server.md also covers installed executables, authentication and Linux deployment.",
|
||||
"cargo run --locked -p oa-server --bin arcadia-server\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
|
||||
),
|
||||
walkthrough(
|
||||
"Build a safe editing client",
|
||||
"Open or publish a hosted model",
|
||||
&[
|
||||
"Choose Hosted models... in the top bar. Enter the service root, such as http://127.0.0.1:8787, without /api/v1. Enter the configured bearer token if required, then Connect / refresh.",
|
||||
"Choose Publish as new to upload the current source project, including its files and comments, as a separate hosted resource. Its acknowledgement switches the current Save destination to that resource.",
|
||||
"To open an existing resource, search the Hosted model catalog, select a model and choose Open selected. More models loads another page. Unsaved work uses the normal save/discard/cancel prompt.",
|
||||
"Edit diagrams, properties or Textual model, then choose Save to server or press Ctrl+S. Valid drafts are applied; invalid drafts remain available to correct or revert. Edits and Undo stay local until Save.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Review another client's changes",
|
||||
&[
|
||||
"Check server reads the current revision without replacing local content. A stale Save returns 412 and automatically checks the server for review; local work is retained.",
|
||||
"Review the changed file list. View server text opens a read-only file; Back to review returns to the list. There is no automatic merge.",
|
||||
"Reload server version adopts the reviewed source project as one undoable local change after checking unapplied drafts. Undo restores the previous accepted local version without changing the server. Reconcile your intended changes before saving again.",
|
||||
"After an unknown write outcome, use Check previous write when offered. If publication may already have succeeded, inspect the refreshed catalog before publishing another copy.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Return to a local file",
|
||||
"Choose Save as local... or Ctrl+Shift+S, then save an .arcadia entry to retain included files and comments. A successful source-project save makes that file the current destination and leaves the hosted resource unchanged. JSON exports the model only and retains the hosted destination and unsaved state. Canceling or a failed save also retains the hosted destination. Forget connection becomes available after leaving the hosted session.",
|
||||
),
|
||||
explain(
|
||||
"Connection credentials",
|
||||
"The desktop keeps credentials in memory only. Use HTTPS remotely; HTTP is allowed only for localhost or a loopback IP. Put the token in its own field, never the service URL. URLs with credentials, queries or fragments are rejected, and redirects are not followed. To update a token, enter it and Connect / refresh for the same service, then retry the check or Save.",
|
||||
),
|
||||
walkthrough(
|
||||
"Automate model edits",
|
||||
&[
|
||||
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
|
||||
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
|
||||
@@ -769,10 +795,6 @@ fn article(topic: Topic) -> Article {
|
||||
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Desktop files and hosted models",
|
||||
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
|
||||
),
|
||||
explain(
|
||||
"Deployment and persistence",
|
||||
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Optional HTTP transport. It never adopts a document or persists credentials.
|
||||
use oa_core::{
|
||||
Id, Project,
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle},
|
||||
};
|
||||
use reqwest::{Client, Method, Url, header};
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use std::{fmt, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
// The service permits two 8 MiB representations plus envelope framing. Keep
|
||||
// transport/parser budgets finite even when connecting to a misconfigured host.
|
||||
const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024;
|
||||
const MAX_PROBLEM_BYTES: usize = 256 * 1024;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(40);
|
||||
const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
|
||||
max_files: 256,
|
||||
max_include_depth: 32,
|
||||
max_include_edges: 1_024,
|
||||
model: oa_core::language::ParseLimits {
|
||||
max_source_bytes: 8 * 1024 * 1024,
|
||||
max_graph_objects: 5_000,
|
||||
max_records: 20_000,
|
||||
max_step_references: 50_000,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Secret(pub String);
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Secret([redacted])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Connection {
|
||||
base: Url,
|
||||
client: Client,
|
||||
token: Secret,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Connection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Do not expose headers, credentials, or user-supplied URL text.
|
||||
f.debug_struct("Connection").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Remote {
|
||||
pub resource_id: Id,
|
||||
pub revision: u64,
|
||||
pub etag: String,
|
||||
pub compiled: Arc<CompiledSourceProject>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Catalog {
|
||||
pub items: Vec<Summary>,
|
||||
pub next_after: Option<Id>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Summary {
|
||||
pub resource_id: Id,
|
||||
pub revision: u64,
|
||||
pub model_id: Id,
|
||||
pub name: String,
|
||||
pub elements: usize,
|
||||
pub relations: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Failure {
|
||||
pub detail: String,
|
||||
pub code: String,
|
||||
pub status: Option<u16>,
|
||||
/// A mutation may have committed. Read current state before another write.
|
||||
pub uncertain: bool,
|
||||
/// Publication was acknowledged, but its accepted source could not be read.
|
||||
pub published: Option<Id>,
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn local(code: &str, detail: &str) -> Self {
|
||||
Self {
|
||||
detail: detail.into(),
|
||||
code: code.into(),
|
||||
status: None,
|
||||
uncertain: false,
|
||||
published: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Failure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Failure {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
resource_id: Id,
|
||||
revision: u64,
|
||||
model: Project,
|
||||
source_project: SourceBundle,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AcceptedSource {
|
||||
revision: u64,
|
||||
source_project: SourceBundle,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Problem {
|
||||
code: String,
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
issues: Vec<ProblemIssue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProblemIssue {
|
||||
message: String,
|
||||
path: Option<String>,
|
||||
line: Option<usize>,
|
||||
column: Option<usize>,
|
||||
}
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
etag: Option<String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// `base` is the service root, optionally beneath a reverse-proxy path.
|
||||
/// Requests append `api/v1/`; credentials belong in `token`, never the URL.
|
||||
pub fn new(base: &str, token: Secret) -> Result<Self, Failure> {
|
||||
Self::with_timeout(base, token, REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
|
||||
let invalid = || {
|
||||
Failure::local(
|
||||
"invalid_endpoint",
|
||||
"Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.",
|
||||
)
|
||||
};
|
||||
let base = base.trim();
|
||||
if base.contains('\\')
|
||||
|| base.chars().any(char::is_control)
|
||||
|| base.split_once("://").is_some_and(|(_, rest)| {
|
||||
rest.split(['/', '?', '#'])
|
||||
.next()
|
||||
.is_some_and(|authority| authority.contains('@'))
|
||||
})
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut base = Url::parse(base).map_err(|_| invalid())?;
|
||||
let host = base.host_str().ok_or_else(invalid)?;
|
||||
let loopback = host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_matches(['[', ']'])
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback());
|
||||
if !(base.scheme() == "https" || (base.scheme() == "http" && loopback))
|
||||
|| !base.username().is_empty()
|
||||
|| base.password().is_some()
|
||||
|| base.query().is_some()
|
||||
|| base.fragment().is_some()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if !base.path().ends_with('/') {
|
||||
// set_path accepts the existing URL's encoded path without decoding
|
||||
// it; this preserves an optional reverse-proxy prefix exactly.
|
||||
base.set_path(&format!("{}/", base.path()));
|
||||
}
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
header::ACCEPT,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if !token.0.is_empty() {
|
||||
if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) {
|
||||
return Err(Failure::local(
|
||||
"invalid_token",
|
||||
"The bearer token must contain printable ASCII without whitespace.",
|
||||
));
|
||||
}
|
||||
let mut value =
|
||||
header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| {
|
||||
Failure::local(
|
||||
"invalid_token",
|
||||
"The bearer token is not a valid HTTP credential.",
|
||||
)
|
||||
})?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert(header::AUTHORIZATION, value);
|
||||
}
|
||||
let mut builder = Client::builder()
|
||||
.default_headers(headers)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.retry(reqwest::retry::never())
|
||||
.connect_timeout(Duration::from_secs(10).min(timeout))
|
||||
.timeout(timeout)
|
||||
.user_agent("Arcadia-Studio/hosted-desktop");
|
||||
if loopback {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
let client = builder.build().map_err(|_| {
|
||||
Failure::local(
|
||||
"client_unavailable",
|
||||
"The HTTP client could not be initialized.",
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
base,
|
||||
client,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base(&self) -> &str {
|
||||
self.base.as_str()
|
||||
}
|
||||
|
||||
fn endpoint(&self, path: &str) -> Url {
|
||||
// All path suffixes are literals or UUIDs, never user-provided paths.
|
||||
self.base
|
||||
.join(&format!("api/v1/{path}"))
|
||||
.expect("validated hierarchical base")
|
||||
}
|
||||
|
||||
fn redact(&self, text: String) -> String {
|
||||
let text = if self.token.0.is_empty() {
|
||||
text
|
||||
} else {
|
||||
text.replace(&self.token.0, "[redacted]")
|
||||
};
|
||||
// Limit user-visible diagnostics independently of the response budget.
|
||||
text.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.take(8_192)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure {
|
||||
Failure {
|
||||
detail: detail.into(),
|
||||
code: "invalid_response".into(),
|
||||
status: Some(reply.status),
|
||||
uncertain: mutation,
|
||||
published: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
|
||||
serde_json::from_slice(&reply.body).map_err(|_| {
|
||||
self.invalid_reply(
|
||||
reply,
|
||||
mutation,
|
||||
"The service returned an invalid JSON response. Local work is unchanged.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn etag(&self, reply: &Reply, mutation: bool) -> Result<String, Failure> {
|
||||
reply
|
||||
.etag
|
||||
.as_deref()
|
||||
.filter(|value| valid_etag(value))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| {
|
||||
self.invalid_reply(
|
||||
reply,
|
||||
mutation,
|
||||
"The service did not return a valid strong ETag. Local work is unchanged.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
bundle: Option<SourceBundle>,
|
||||
etag: Option<&str>,
|
||||
) -> Result<Reply, Failure> {
|
||||
let mutation = method != Method::GET;
|
||||
let expected_status = if method == Method::POST { 201 } else { 200 };
|
||||
let mut request = self.client.request(method, url);
|
||||
if let Some(bundle) = bundle {
|
||||
let bytes = serde_json::to_vec(&bundle).map_err(|_| {
|
||||
Failure::local(
|
||||
"invalid_source_project",
|
||||
"The source project could not be encoded for the service.",
|
||||
)
|
||||
})?;
|
||||
if bytes.len() > 8 * 1024 * 1024 {
|
||||
return Err(Failure::local(
|
||||
"request_too_large",
|
||||
"The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.",
|
||||
));
|
||||
}
|
||||
request = request
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(bytes);
|
||||
}
|
||||
if let Some(etag) = etag {
|
||||
if !valid_etag(etag) {
|
||||
return Err(Failure::local(
|
||||
"invalid_etag",
|
||||
"A captured strong server ETag is required to save. Check the server version first.",
|
||||
));
|
||||
}
|
||||
request = request.header(header::IF_MATCH, etag);
|
||||
}
|
||||
let mut response = request.send().await.map_err(|error| Failure {
|
||||
detail: if error.is_timeout() {
|
||||
"The service request timed out. Check the server before retrying a write."
|
||||
} else {
|
||||
"The service could not be reached or the connection was interrupted. Check the URL, service and credentials."
|
||||
}.into(),
|
||||
code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(),
|
||||
status: None,
|
||||
uncertain: mutation && !error.is_connect() && !error.is_builder(),
|
||||
published: None,
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let limit = if response.status().is_success() {
|
||||
MAX_RESPONSE_BYTES
|
||||
} else {
|
||||
MAX_PROBLEM_BYTES
|
||||
};
|
||||
let uncertain =
|
||||
mutation && (response.status().is_success() || response.status().is_server_error());
|
||||
let oversized = || Failure {
|
||||
detail:
|
||||
"The service response exceeds the desktop transport limit. Local work is unchanged."
|
||||
.into(),
|
||||
code: "response_too_large".into(),
|
||||
status: Some(status),
|
||||
uncertain,
|
||||
published: None,
|
||||
};
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > limit as u64)
|
||||
{
|
||||
return Err(oversized());
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| Failure {
|
||||
detail:
|
||||
"The service response was interrupted. Check the server before retrying a write."
|
||||
.into(),
|
||||
code: "response_interrupted".into(),
|
||||
status: Some(status),
|
||||
uncertain,
|
||||
published: None,
|
||||
})? {
|
||||
if chunk.len() > limit.saturating_sub(body.len()) {
|
||||
return Err(oversized());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
if status != expected_status {
|
||||
let mut failure = Failure {
|
||||
detail: format!("The service returned HTTP {status}. Local work is unchanged."),
|
||||
code: if (300..400).contains(&status) {
|
||||
"redirect_refused"
|
||||
} else {
|
||||
"http_error"
|
||||
}
|
||||
.into(),
|
||||
status: Some(status),
|
||||
uncertain,
|
||||
published: None,
|
||||
};
|
||||
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
|
||||
failure.uncertain |= mutation && problem.code == "outcome_unknown";
|
||||
// These service failures explicitly precede admission/commit.
|
||||
// Other 5xx responses remain uncertain, including storage I/O.
|
||||
if matches!(
|
||||
(status, problem.code.as_str()),
|
||||
(503, "overloaded") | (507, "store_capacity")
|
||||
) {
|
||||
failure.uncertain = false;
|
||||
}
|
||||
if !problem.code.is_empty()
|
||||
&& problem.code.len() <= 80
|
||||
&& problem
|
||||
.code
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
failure.code = self.redact(problem.code);
|
||||
}
|
||||
let mut detail = problem.detail;
|
||||
for issue in problem.issues.into_iter().take(20) {
|
||||
detail.push('\n');
|
||||
if let Some(path) = issue.path {
|
||||
detail.push_str(&path);
|
||||
if let Some(line) = issue.line {
|
||||
detail.push_str(&format!(":{line}"));
|
||||
if let Some(column) = issue.column {
|
||||
detail.push_str(&format!(":{column}"));
|
||||
}
|
||||
}
|
||||
detail.push_str(": ");
|
||||
}
|
||||
detail.push_str(&issue.message);
|
||||
}
|
||||
failure.detail = self.redact(detail);
|
||||
}
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(Reply { status, etag, body })
|
||||
}
|
||||
|
||||
pub async fn catalog(&self, query: &str, after: Option<Id>) -> Result<Catalog, Failure> {
|
||||
let mut url = self.endpoint("projects");
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.append_pair("limit", "100");
|
||||
if !query.is_empty() {
|
||||
pairs.append_pair("q", query);
|
||||
}
|
||||
if let Some(after) = after {
|
||||
pairs.append_pair("after", &after.to_string());
|
||||
}
|
||||
}
|
||||
let reply = self.request(Method::GET, url, None, None).await?;
|
||||
let catalog: Catalog = self.decode(&reply, false)?;
|
||||
let mut previous = after;
|
||||
for item in &catalog.items {
|
||||
if item.resource_id.is_nil()
|
||||
|| item.model_id.is_nil()
|
||||
|| item.revision == 0
|
||||
|| previous.is_some_and(|id| item.resource_id <= id)
|
||||
{
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The service returned an invalid project catalog.",
|
||||
));
|
||||
}
|
||||
previous = Some(item.resource_id);
|
||||
}
|
||||
if catalog.items.len() > 100
|
||||
|| catalog
|
||||
.next_after
|
||||
.is_some_and(|id| Some(id) != previous || catalog.items.is_empty())
|
||||
{
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The service returned invalid catalog pagination.",
|
||||
));
|
||||
}
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub async fn open(&self, id: Id) -> Result<Remote, Failure> {
|
||||
let reply = self
|
||||
.request(
|
||||
Method::GET,
|
||||
self.endpoint(&format!("projects/{id}")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let envelope: Envelope = self.decode(&reply, false)?;
|
||||
let etag = self.etag(&reply, false)?;
|
||||
if envelope.resource_id != id
|
||||
|| id.is_nil()
|
||||
|| envelope.revision == 0
|
||||
|| envelope.model.id.is_nil()
|
||||
{
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The service returned a different project identity or invalid revision.",
|
||||
));
|
||||
}
|
||||
let compiled = self.compile(envelope.source_project, &reply, false).await?;
|
||||
if compiled.decoded.project != envelope.model {
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
false,
|
||||
"The hosted source and model disagree. The document was not opened.",
|
||||
));
|
||||
}
|
||||
Ok(Remote {
|
||||
resource_id: id,
|
||||
revision: envelope.revision,
|
||||
etag,
|
||||
compiled,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn publish(&self, bundle: SourceBundle) -> Result<Remote, Failure> {
|
||||
let reply = self
|
||||
.request(
|
||||
Method::POST,
|
||||
self.endpoint("projects/import-source-project"),
|
||||
Some(bundle),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let receipt: Summary = self.decode(&reply, true)?;
|
||||
if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 {
|
||||
return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again."));
|
||||
}
|
||||
let published = Some(receipt.resource_id);
|
||||
let etag = self.etag(&reply, true).map_err(|mut failure| {
|
||||
failure.published = published;
|
||||
failure
|
||||
})?;
|
||||
let remote = self.open(receipt.resource_id).await.map_err(|mut failure| {
|
||||
failure.uncertain = true;
|
||||
failure.published = published;
|
||||
failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail);
|
||||
failure
|
||||
})?;
|
||||
if remote.revision != receipt.revision
|
||||
|| remote.etag != etag
|
||||
|| remote.compiled.decoded.project.id != receipt.model_id
|
||||
{
|
||||
let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again.");
|
||||
failure.published = published;
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result<Remote, Failure> {
|
||||
let reply = self
|
||||
.request(
|
||||
Method::PUT,
|
||||
self.endpoint(&format!("projects/{id}/source-project")),
|
||||
Some(bundle),
|
||||
Some(etag),
|
||||
)
|
||||
.await?;
|
||||
let accepted: AcceptedSource = self.decode(&reply, true)?;
|
||||
let etag = self.etag(&reply, true)?;
|
||||
if id.is_nil() || accepted.revision == 0 {
|
||||
return Err(self.invalid_reply(
|
||||
&reply,
|
||||
true,
|
||||
"The service returned an invalid saved revision.",
|
||||
));
|
||||
}
|
||||
let compiled = self.compile(accepted.source_project, &reply, true).await?;
|
||||
Ok(Remote {
|
||||
resource_id: id,
|
||||
revision: accepted.revision,
|
||||
etag,
|
||||
compiled,
|
||||
})
|
||||
}
|
||||
|
||||
async fn compile(
|
||||
&self,
|
||||
bundle: SourceBundle,
|
||||
reply: &Reply,
|
||||
mutation: bool,
|
||||
) -> Result<Arc<CompiledSourceProject>, Failure> {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
|
||||
Ok::<_, Vec<source_project::SourceDiagnostic>>((compiled, bundle))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
self.invalid_reply(
|
||||
reply,
|
||||
mutation,
|
||||
"The hosted source could not be checked. Local work is unchanged.",
|
||||
)
|
||||
})?;
|
||||
match result {
|
||||
Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)),
|
||||
Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")),
|
||||
Err(issues) => {
|
||||
let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged.");
|
||||
for issue in issues.into_iter().take(10) {
|
||||
failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message));
|
||||
}
|
||||
failure.detail = self.redact(failure.detail);
|
||||
Err(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_etag(value: &str) -> bool {
|
||||
(2..=1_024).contains(&value.len())
|
||||
&& value.starts_with('"')
|
||||
&& value.ends_with('"')
|
||||
&& value.as_bytes()[1..value.len() - 1]
|
||||
.iter()
|
||||
.all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "client_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,609 @@
|
||||
use super::*;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
struct TestServer {
|
||||
base: String,
|
||||
stop: Option<oneshot::Sender<()>>,
|
||||
task: JoinHandle<()>,
|
||||
_directory: Option<tempfile::TempDir>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
async fn fixture(router: Router) -> Self {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (stop, done) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self {
|
||||
base,
|
||||
stop: Some(stop),
|
||||
task,
|
||||
_directory: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn production(token: Option<String>, prefix: bool) -> Self {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let store = Arc::new(
|
||||
tokio::task::spawn_blocking(move || oa_server::store::Store::open(path))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let router = oa_server::app(store, token);
|
||||
let router = if prefix {
|
||||
Router::new().nest("/teams/engineering", router)
|
||||
} else {
|
||||
router
|
||||
};
|
||||
let mut server = Self::fixture(router).await;
|
||||
server._directory = Some(directory);
|
||||
if prefix {
|
||||
server.base.push_str("/teams/engineering");
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
fn connection(&self) -> Connection {
|
||||
Connection::new(&self.base, Secret::default()).unwrap()
|
||||
}
|
||||
|
||||
async fn finish(mut self) {
|
||||
self.stop.take().unwrap().send(()).unwrap();
|
||||
(&mut self.task).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn authored() -> SourceBundle {
|
||||
let bundle = SourceBundle {
|
||||
entry: "harbor.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()),
|
||||
("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()),
|
||||
]),
|
||||
};
|
||||
source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||
.unwrap()
|
||||
.bundle
|
||||
}
|
||||
|
||||
fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value {
|
||||
let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project;
|
||||
json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() {
|
||||
for base in [
|
||||
"http://example.com",
|
||||
"http://192.168.1.4",
|
||||
"ftp://localhost",
|
||||
"not a url",
|
||||
"https://user:secret@example.com",
|
||||
"https://@example.com",
|
||||
"https://user@example.com",
|
||||
"https://example.com/?token=secret",
|
||||
"https://example.com/#secret",
|
||||
"https://example.com\\hidden",
|
||||
"https://example.com/\nsecret",
|
||||
] {
|
||||
let failure = Connection::new(base, Secret("secret".into())).unwrap_err();
|
||||
assert_eq!(failure.code, "invalid_endpoint");
|
||||
assert!(!failure.detail.contains("secret"));
|
||||
assert!(!failure.uncertain);
|
||||
}
|
||||
for base in [
|
||||
"http://localhost:8787",
|
||||
"http://127.0.0.2:8787",
|
||||
"http://[::1]:8787",
|
||||
"https://example.com/prefix",
|
||||
] {
|
||||
assert!(Connection::new(base, Secret::default()).is_ok(), "{base}");
|
||||
}
|
||||
let client = Connection::new(
|
||||
"https://example.com/teams/space%20name",
|
||||
Secret("private-token".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(client.base(), "https://example.com/teams/space%20name/");
|
||||
assert_eq!(
|
||||
client.endpoint("projects").as_str(),
|
||||
"https://example.com/teams/space%20name/api/v1/projects"
|
||||
);
|
||||
assert!(!format!("{client:?}").contains("private-token"));
|
||||
assert_eq!(
|
||||
format!("{:?}", Secret("private-token".into())),
|
||||
"Secret([redacted])"
|
||||
);
|
||||
assert_eq!(
|
||||
Connection::new("http://localhost", Secret("bad\nheader".into()))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"invalid_token"
|
||||
);
|
||||
for etag in [
|
||||
"*",
|
||||
"W/\"weak\"",
|
||||
"unquoted",
|
||||
"\"one\",\"two\"",
|
||||
"\"new\nline\"",
|
||||
] {
|
||||
assert!(!valid_etag(etag), "{etag}");
|
||||
}
|
||||
assert!(valid_etag("\"opaque:tag/+,=?\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() {
|
||||
let server = TestServer::production(None, false).await;
|
||||
let client = server.connection();
|
||||
assert!(client.catalog("", None).await.unwrap().items.is_empty());
|
||||
let bundle = authored();
|
||||
let original = client.publish(bundle.clone()).await.unwrap();
|
||||
assert_ne!(original.resource_id, original.compiled.decoded.project.id);
|
||||
assert_eq!(original.compiled.bundle, bundle);
|
||||
assert_eq!(original.revision, 1);
|
||||
let catalog = client.catalog("Harbor &", None).await.unwrap();
|
||||
assert_eq!(catalog.items.len(), 1);
|
||||
assert_eq!(catalog.items[0].resource_id, original.resource_id);
|
||||
assert_eq!(
|
||||
catalog.items[0].model_id,
|
||||
original.compiled.decoded.project.id
|
||||
);
|
||||
assert_eq!(catalog.items[0].name, "Harbor & rescue");
|
||||
assert_eq!(
|
||||
(catalog.items[0].elements, catalog.items[0].relations),
|
||||
(2, 0)
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.catalog("?q=missing&limit=1000", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.catalog("", Some(original.resource_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
let same = client
|
||||
.save(original.resource_id, &original.etag, bundle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(same.etag, original.etag);
|
||||
assert_eq!(same.revision, original.revision);
|
||||
let mut changed = bundle.clone();
|
||||
changed
|
||||
.files
|
||||
.get_mut("work/operations.arcadia")
|
||||
.unwrap()
|
||||
.push_str("// Reviewed by the team\r\n");
|
||||
let saved = client
|
||||
.save(original.resource_id, &original.etag, changed.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.resource_id, original.resource_id);
|
||||
assert_eq!(saved.revision, 2);
|
||||
assert_ne!(saved.etag, original.etag);
|
||||
assert_eq!(saved.compiled.bundle, changed);
|
||||
assert_eq!(
|
||||
saved.compiled.decoded.project,
|
||||
original.compiled.decoded.project
|
||||
);
|
||||
let conflict = client
|
||||
.save(original.resource_id, &original.etag, bundle)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(conflict.status, Some(412));
|
||||
assert_eq!(conflict.code, "revision_conflict");
|
||||
assert!(!conflict.uncertain);
|
||||
assert_eq!(
|
||||
client
|
||||
.open(original.resource_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.compiled
|
||||
.bundle,
|
||||
changed
|
||||
);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_production_auth_proxy_prefix_and_located_rejection() {
|
||||
let token = "hosted-test-token-with-at-least-32-characters";
|
||||
let server = TestServer::production(Some(token.into()), true).await;
|
||||
let unauthorized = server.connection().catalog("", None).await.unwrap_err();
|
||||
assert_eq!(
|
||||
(unauthorized.status, unauthorized.code.as_str()),
|
||||
(Some(401), "unauthorized")
|
||||
);
|
||||
let client = Connection::new(&server.base, Secret(token.into())).unwrap();
|
||||
let original = client.publish(authored()).await.unwrap();
|
||||
assert_eq!(
|
||||
client.catalog("Harbor &", None).await.unwrap().items.len(),
|
||||
1
|
||||
);
|
||||
let mut invalid = original.compiled.bundle.clone();
|
||||
invalid
|
||||
.files
|
||||
.get_mut("work/operations.arcadia")
|
||||
.unwrap()
|
||||
.push_str("this is not valid source;");
|
||||
let rejected = client
|
||||
.save(original.resource_id, &original.etag, invalid)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(rejected.status, Some(422));
|
||||
assert!(!rejected.uncertain);
|
||||
assert!(rejected.detail.contains("work/operations.arcadia:"));
|
||||
assert_eq!(
|
||||
client.open(original.resource_id).await.unwrap().etag,
|
||||
original.etag
|
||||
);
|
||||
let missing = client.open(Id::new_v4()).await.unwrap_err();
|
||||
assert_eq!(missing.status, Some(404));
|
||||
assert!(!missing.uncertain);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() {
|
||||
let redirected = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&redirected);
|
||||
let destination = TestServer::fixture(Router::new().fallback(move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
StatusCode::OK
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let location = destination.base.clone();
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let seen = Arc::clone(&attempts);
|
||||
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
|
||||
let location = location.clone();
|
||||
let seen = Arc::clone(&seen);
|
||||
async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) }
|
||||
})).route("/api/v1/projects", get(|| async {
|
||||
(StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]})))
|
||||
}))).await;
|
||||
let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap();
|
||||
let failure = client.publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.code, "redirect_refused");
|
||||
assert!(!failure.uncertain);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(redirected.load(Ordering::SeqCst), 0);
|
||||
let failure = client.catalog("", None).await.unwrap_err();
|
||||
assert!(failure.detail.contains("[redacted]"));
|
||||
assert!(!format!("{failure:?}").contains("private-test-token"));
|
||||
server.finish().await;
|
||||
destination.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() {
|
||||
let id = Id::new_v4();
|
||||
let stable = authored();
|
||||
let mut mismatched = envelope(stable.clone(), id, 1);
|
||||
mismatched["model"]["name"] = json!("different model");
|
||||
let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1);
|
||||
// Keep this independently useful even if the model's schema changes.
|
||||
wrong_id["revision"] = json!(1);
|
||||
let mut unstable = envelope(stable.clone(), id, 1);
|
||||
unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}});
|
||||
for (body, etag) in [
|
||||
(mismatched, Some("\"opaque\"")),
|
||||
(wrong_id, Some("\"opaque\"")),
|
||||
(envelope(stable.clone(), id, 0), Some("\"opaque\"")),
|
||||
(envelope(stable, id, 1), None),
|
||||
(unstable, Some("\"opaque\"")),
|
||||
] {
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/{id}",
|
||||
get(move || {
|
||||
let body = body.clone();
|
||||
async move {
|
||||
let mut response = Json(body).into_response();
|
||||
if let Some(etag) = etag {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::ETAG, etag.parse().unwrap());
|
||||
}
|
||||
response
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
let failure = server.connection().open(id).await.unwrap_err();
|
||||
assert_eq!(failure.code, "invalid_response");
|
||||
assert!(!failure.uncertain);
|
||||
server.finish().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() {
|
||||
let id = Id::new_v4();
|
||||
let etag = "\"opaque:tag/+,=?\"";
|
||||
let bundle = authored();
|
||||
let response_bundle = bundle.clone();
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/{id}/source-project",
|
||||
put(
|
||||
move |headers: header::HeaderMap, Json(body): Json<SourceBundle>| {
|
||||
let expected_bundle = response_bundle.clone();
|
||||
async move {
|
||||
assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag);
|
||||
assert_eq!(body, expected_bundle);
|
||||
(
|
||||
[(header::ETAG, "\"accepted:new-tag\"")],
|
||||
Json(json!({"revision":8,"changed":false,"source_project":body})),
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
))
|
||||
.await;
|
||||
let client = server.connection();
|
||||
let saved = client.save(id, etag, bundle.clone()).await.unwrap();
|
||||
assert_eq!(saved.etag, "\"accepted:new-tag\"");
|
||||
assert_eq!(saved.resource_id, id);
|
||||
assert_eq!(saved.revision, 8);
|
||||
assert_eq!(saved.compiled.bundle, bundle.clone());
|
||||
assert_eq!(
|
||||
client.save(id, "*", bundle).await.unwrap_err().code,
|
||||
"invalid_etag"
|
||||
);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_publication_retains_known_resource_after_failed_followup() {
|
||||
let id = Id::new_v4();
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&attempts);
|
||||
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
(StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0})))
|
||||
}
|
||||
})).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await;
|
||||
let failure = server.connection().publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.published, Some(id));
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(failure.status, Some(503));
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
/// Reads the entire request before simulating a lost/malformed response. The
|
||||
/// counter proves the production client never repeats an admitted publication.
|
||||
async fn raw_fixture(reply: Vec<u8>) -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&attempts);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0; 4096];
|
||||
loop {
|
||||
let read = stream.read(&mut buffer).await.unwrap();
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("content-length:")
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = stream.write_all(&reply).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
});
|
||||
(base, attempts, task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_lost_publication_response_is_uncertain_without_retry() {
|
||||
let (base, attempts, task) = raw_fixture(Vec::new()).await;
|
||||
let client = Connection::new(&base, Secret::default()).unwrap();
|
||||
let failure = client.publish(authored()).await.unwrap_err();
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(failure.published, None);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
|
||||
let declared = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
MAX_RESPONSE_BYTES + 1
|
||||
)
|
||||
.into_bytes();
|
||||
let mut chunked =
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec();
|
||||
let chunk = vec![b' '; 64 * 1024];
|
||||
for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) {
|
||||
chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
|
||||
chunked.extend_from_slice(&chunk);
|
||||
chunked.extend_from_slice(b"\r\n");
|
||||
}
|
||||
chunked.extend_from_slice(b"0\r\n\r\n");
|
||||
for reply in [declared, chunked] {
|
||||
let (base, _, task) = raw_fixture(reply).await;
|
||||
let failure = Connection::new(&base, Secret::default())
|
||||
.unwrap()
|
||||
.catalog("", None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.code, "response_too_large");
|
||||
assert!(!failure.uncertain);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
|
||||
let received = Arc::new(AtomicUsize::new(0));
|
||||
let calls = Arc::clone(&received);
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/import-source-project",
|
||||
post(move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
StatusCode::CREATED
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
let client =
|
||||
Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30))
|
||||
.unwrap();
|
||||
let failure = client.publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.code, "timeout");
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(received.load(Ordering::SeqCst), 1);
|
||||
let base = server.base.clone();
|
||||
server.finish().await;
|
||||
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.catalog("", None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure.code.as_str(),
|
||||
"connection_failed" | "timeout"
|
||||
));
|
||||
assert!(!failure.uncertain);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_publication_race_keeps_receipt_identity_for_review() {
|
||||
let id = Id::new_v4();
|
||||
let bundle = authored();
|
||||
let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project
|
||||
.id;
|
||||
let mut changed = bundle.clone();
|
||||
changed
|
||||
.files
|
||||
.get_mut("work/operations.arcadia")
|
||||
.unwrap()
|
||||
.push_str("// Concurrent external review\r\n");
|
||||
let head = envelope(changed, id, 2);
|
||||
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move {
|
||||
(StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0})))
|
||||
})).route("/api/v1/projects/{id}", get(move || {
|
||||
let head = head.clone();
|
||||
async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) }
|
||||
}))).await;
|
||||
let failure = server.connection().publish(bundle).await.unwrap_err();
|
||||
assert!(failure.uncertain);
|
||||
assert_eq!(failure.published, Some(id));
|
||||
assert!(failure.detail.contains("changed before"));
|
||||
server.finish().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() {
|
||||
for (status, code, uncertain) in [
|
||||
(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false),
|
||||
(StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false),
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", false),
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true),
|
||||
(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true),
|
||||
] {
|
||||
let server = TestServer::fixture(Router::new().route(
|
||||
"/api/v1/projects/import-source-project",
|
||||
post(move || async move {
|
||||
(
|
||||
status,
|
||||
Json(json!({"code":code,"detail":"Service-specific actionable detail"})),
|
||||
)
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
let failure = server.connection().publish(authored()).await.unwrap_err();
|
||||
assert_eq!(failure.status, Some(status.as_u16()));
|
||||
assert_eq!(failure.code, code);
|
||||
assert_eq!(failure.uncertain, uncertain, "{code}");
|
||||
assert_eq!(failure.detail, "Service-specific actionable detail");
|
||||
server.finish().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() {
|
||||
let mut bundle = authored();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str(&" ".repeat(8 * 1024 * 1024));
|
||||
let failure = Connection::new("http://127.0.0.1:1", Secret::default())
|
||||
.unwrap()
|
||||
.publish(bundle)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.code, "request_too_large");
|
||||
assert!(!failure.uncertain);
|
||||
assert!(failure.published.is_none());
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Optional hosted transport state. The Document remains the local editing history.
|
||||
pub mod client;
|
||||
pub mod view;
|
||||
|
||||
use client::{Catalog, Connection, Failure, Remote, Secret, Summary};
|
||||
use oa_core::{Id, source_project::CompiledSourceProject};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Show,
|
||||
Close,
|
||||
Base(String),
|
||||
Token(Secret),
|
||||
Query(String),
|
||||
Connect,
|
||||
More,
|
||||
Select(Id),
|
||||
OpenSelected,
|
||||
Publish,
|
||||
Save,
|
||||
Check,
|
||||
Reload,
|
||||
ReviewFile(String),
|
||||
ReviewAction(iced::widget::text_editor::Action),
|
||||
ReviewBack,
|
||||
Local,
|
||||
Forget,
|
||||
Completed(Id, Id, Result<Outcome, Failure>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
Remote(Remote),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Operation {
|
||||
Catalog(bool),
|
||||
Open(Id),
|
||||
Publish,
|
||||
Save,
|
||||
Check,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub connection: Connection,
|
||||
pub resource_id: Id,
|
||||
pub revision: u64,
|
||||
pub etag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub id: Id,
|
||||
pub document: Id,
|
||||
pub revision: u64,
|
||||
pub operation: Operation,
|
||||
pub connection: Connection,
|
||||
pub resource_id: Option<Id>,
|
||||
pub etag: Option<String>,
|
||||
pub snapshot: Arc<CompiledSourceProject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Review {
|
||||
pub connection: Connection,
|
||||
pub remote: Remote,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub visible: bool,
|
||||
pub base: String,
|
||||
pub token: Secret,
|
||||
pub query: String,
|
||||
pub catalog: Vec<Summary>,
|
||||
pub catalog_stale: bool,
|
||||
pub next: Option<Id>,
|
||||
pub selected: Option<Id>,
|
||||
pub connection: Option<Connection>,
|
||||
pub session: Option<Session>,
|
||||
pub request: Option<Request>,
|
||||
pub uncertain: Option<Request>,
|
||||
pub review: Option<Review>,
|
||||
pub review_path: Option<String>,
|
||||
pub review_editor: iced::widget::text_editor::Content,
|
||||
pub error: Option<Failure>,
|
||||
pub open_target: Option<(Connection, Id)>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
visible: false,
|
||||
base: "http://127.0.0.1:8787".into(),
|
||||
token: Secret::default(),
|
||||
query: String::new(),
|
||||
catalog: vec![],
|
||||
catalog_stale: true,
|
||||
next: None,
|
||||
selected: None,
|
||||
connection: None,
|
||||
session: None,
|
||||
request: None,
|
||||
uncertain: None,
|
||||
review: None,
|
||||
review_path: None,
|
||||
review_editor: iced::widget::text_editor::Content::new(),
|
||||
error: None,
|
||||
open_target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn invalidate_catalog(&mut self) {
|
||||
self.catalog_stale = true;
|
||||
self.next = None;
|
||||
}
|
||||
|
||||
pub fn catalog_message(&self) -> Option<&'static str> {
|
||||
if self.connection.is_none() {
|
||||
Some("Connect / refresh to load models for this service.")
|
||||
} else if self.catalog_stale {
|
||||
Some("Catalog may be out of date. Connect / refresh to update these results.")
|
||||
} else if self.catalog.is_empty() {
|
||||
Some("No models found. Publish the current model or change the search.")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep explicit connection settings in memory, but never carry a resource
|
||||
/// binding or in-flight result into another local document.
|
||||
pub fn leave_document(&mut self) {
|
||||
self.visible = false;
|
||||
self.session = None;
|
||||
self.request = None;
|
||||
self.uncertain = None;
|
||||
self.review = None;
|
||||
self.review_path = None;
|
||||
self.review_editor = iced::widget::text_editor::Content::new();
|
||||
self.error = None;
|
||||
self.open_target = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Hosted dialog. Main-header integration is a small caller-owned hook.
|
||||
use super::{Event, Operation, client::Secret};
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
style,
|
||||
views::{action, label},
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill, Font,
|
||||
widget::{self, button, column, container, row, scrollable, text, text_editor, text_input},
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||
if !app.hosted.visible
|
||||
|| app.pending.is_some()
|
||||
|| app.file_prompt.is_some()
|
||||
|| app.workflow.is_some()
|
||||
|| app.delete_pending
|
||||
{
|
||||
return base;
|
||||
}
|
||||
widget::stack![
|
||||
base,
|
||||
widget::opaque(
|
||||
container(
|
||||
container(dialog(app))
|
||||
.padding(22)
|
||||
.width((app.window_size.width - 64.).clamp(680., 1000.))
|
||||
.height((app.window_size.height - 80.).max(400.))
|
||||
.style(style::card)
|
||||
)
|
||||
.center(Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
fn control<'a>(title: &'a str, event: Event, enabled: bool) -> widget::Button<'a, Message> {
|
||||
button(text(title).size(12))
|
||||
.padding([9, 12])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(enabled.then_some(Message::Hosted(event)))
|
||||
}
|
||||
|
||||
fn dialog(app: &App) -> Element<'_, Message> {
|
||||
let state = &app.hosted;
|
||||
let enabled = !app.busy;
|
||||
if let Some(path) = &state.review_path {
|
||||
return column![
|
||||
text("Server source - read only").size(22),
|
||||
text(path).size(13),
|
||||
text_editor(&state.review_editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(13)
|
||||
.height(Fill)
|
||||
.on_action(|action| Message::Hosted(Event::ReviewAction(action))),
|
||||
control("Back to review", Event::ReviewBack, enabled),
|
||||
]
|
||||
.spacing(12)
|
||||
.into();
|
||||
}
|
||||
let mut content = column![
|
||||
label("SERVICE ADDRESS"),
|
||||
text_input("http://127.0.0.1:8787", &state.base)
|
||||
.padding(10)
|
||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
|
||||
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
|
||||
text_input("Optional for a loopback service", &state.token.0)
|
||||
.secure(true)
|
||||
.padding(10)
|
||||
.on_input_maybe(
|
||||
enabled.then_some(|value| Message::Hosted(Event::Token(Secret(value))))
|
||||
),
|
||||
row![
|
||||
control("Connect / refresh", Event::Connect, enabled),
|
||||
control(
|
||||
"Forget connection",
|
||||
Event::Forget,
|
||||
enabled && state.session.is_none()
|
||||
),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(8);
|
||||
if let Some(session) = &state.session {
|
||||
content = content.push(container(column![
|
||||
text("Current hosted model").size(16),
|
||||
text(session.connection.base()).size(12),
|
||||
text(format!("Resource {} - revision {}", session.resource_id, session.revision)).size(12),
|
||||
label("Edits and Undo stay local. Save sends the complete authored source project."),
|
||||
row![
|
||||
control("Check server", Event::Check, enabled),
|
||||
control("Save as local...", Event::Local, enabled),
|
||||
].spacing(8),
|
||||
].spacing(8)).padding(12).style(style::panel));
|
||||
}
|
||||
if let Some(error) = &state.error {
|
||||
content = content.push(
|
||||
container(
|
||||
column![
|
||||
text(if error.uncertain {
|
||||
"The write outcome is unknown"
|
||||
} else {
|
||||
"Hosted action needs attention"
|
||||
})
|
||||
.size(16),
|
||||
text(&error.detail).size(13),
|
||||
label("Your local model and source history are retained."),
|
||||
]
|
||||
.spacing(7),
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::panel),
|
||||
);
|
||||
}
|
||||
if let Some(failed) = &state.uncertain {
|
||||
if failed.operation == Operation::Publish {
|
||||
content = content.push(text("A previous publication may already exist. Inspect the catalog before publishing another copy.").size(13));
|
||||
}
|
||||
if failed.resource_id.is_some() {
|
||||
content = content.push(control("Check previous write", Event::Check, enabled));
|
||||
}
|
||||
}
|
||||
if let Some(review) = &state.review {
|
||||
let remote = &review.remote;
|
||||
let local = &app.document.bundle().files;
|
||||
let server = &remote.compiled.bundle.files;
|
||||
let paths: BTreeSet<_> = local.keys().chain(server.keys()).collect();
|
||||
let mut changes = column![
|
||||
text(format!("Review server revision {}", remote.revision)).size(17),
|
||||
label(format!("{} - resource {}", review.connection.base(), remote.resource_id)),
|
||||
label("Reload replaces accepted local content as one undoable change. Unapplied drafts are checked first."),
|
||||
].spacing(8);
|
||||
if app.document.bundle().entry != remote.compiled.bundle.entry {
|
||||
changes = changes.push(
|
||||
text(format!(
|
||||
"Entry: {} -> {}",
|
||||
app.document.bundle().entry,
|
||||
remote.compiled.bundle.entry
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
for path in paths {
|
||||
if local.get(path) != server.get(path) {
|
||||
let change = match (local.contains_key(path), server.contains_key(path)) {
|
||||
(false, true) => "Added on server",
|
||||
(true, false) => "Removed on server",
|
||||
_ => "Changed",
|
||||
};
|
||||
changes = changes.push(
|
||||
row![
|
||||
text(format!("{change}: {path}")).size(12).width(Fill),
|
||||
control(
|
||||
"View server text",
|
||||
Event::ReviewFile(path.clone()),
|
||||
enabled && server.contains_key(path)
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
}
|
||||
}
|
||||
changes = changes.push(control("Reload server version", Event::Reload, enabled));
|
||||
content = content.push(container(changes).padding(12).style(style::panel));
|
||||
}
|
||||
content = content.push(text("Hosted model catalog").size(17)).push(
|
||||
row![
|
||||
text_input("Search model names", &state.query)
|
||||
.padding(9)
|
||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
|
||||
.on_submit(Message::Hosted(Event::Connect)),
|
||||
control("Search", Event::Connect, enabled),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
if let Some(message) = state.catalog_message() {
|
||||
content = content.push(label(message));
|
||||
}
|
||||
for item in &state.catalog {
|
||||
let selected = state.selected == Some(item.resource_id);
|
||||
content = content.push(
|
||||
button(
|
||||
column![
|
||||
text(&item.name).size(14),
|
||||
label(format!(
|
||||
"{} - revision {} - {} objects / {} links",
|
||||
item.resource_id, item.revision, item.elements, item.relations
|
||||
)),
|
||||
]
|
||||
.spacing(4),
|
||||
)
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.style(if selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press_maybe(enabled.then_some(Message::Hosted(Event::Select(item.resource_id)))),
|
||||
);
|
||||
}
|
||||
if !state.catalog_stale && state.next.is_some() {
|
||||
content = content.push(control("More models", Event::More, enabled));
|
||||
}
|
||||
let publish_label = if state
|
||||
.uncertain
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.operation == Operation::Publish)
|
||||
{
|
||||
"Publish another copy"
|
||||
} else {
|
||||
"Publish as new"
|
||||
};
|
||||
column![
|
||||
text("Hosted models").size(24),
|
||||
label("Optional shared storage for desktop, scripts and engineering integrations."),
|
||||
scrollable(content.spacing(12)).height(Fill),
|
||||
text(if app.busy { app.status.as_str() } else { "Publishing creates a separate hosted resource. Existing resources change only through Save to server." }).size(12),
|
||||
row![
|
||||
control("Close", Event::Close, enabled),
|
||||
control(publish_label, Event::Publish, enabled),
|
||||
control("Open selected", Event::OpenSelected, enabled && state.selected.is_some() && state.connection.is_some()),
|
||||
control("Save to server", Event::Save, enabled && state.session.is_some() && state.review.is_none()),
|
||||
].spacing(8),
|
||||
].spacing(12).into()
|
||||
}
|
||||
|
||||
// Kept here so a caller can integrate the feature without duplicating event wiring.
|
||||
pub fn header_action() -> widget::Button<'static, Message> {
|
||||
action("Hosted models...", Message::Hosted(Event::Show))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod hosted;
|
||||
mod model_menu;
|
||||
mod navigation;
|
||||
mod port_edit;
|
||||
|
||||
@@ -184,22 +184,28 @@ impl State {
|
||||
d.edit_text(&path, action);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Shape(path, value) => self.edit(project, &path, |d| d.shape(&path, value)),
|
||||
Event::Shape(path, value) => {
|
||||
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||
}
|
||||
Event::Variant(path, value) => self.edit(project, &path, |d| {
|
||||
if d.materialize()?
|
||||
if d.value
|
||||
.pointer(&path)
|
||||
.is_some_and(|v| crate::forms::same_variant(v, &value))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
d.shape(&path, value)
|
||||
d.shape_scoped(&path, value)
|
||||
}
|
||||
}),
|
||||
Event::Array(path, index, direction) => {
|
||||
self.edit(project, &path, |d| d.array(&path, index, direction))
|
||||
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
||||
}
|
||||
Event::Append(path, value) => {
|
||||
self.edit(project, &path, |d| d.append_scoped(&path, value))
|
||||
}
|
||||
Event::ValuePath(path, key) => {
|
||||
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
|
||||
}
|
||||
Event::Append(path, value) => self.edit(project, &path, |d| d.append(&path, value)),
|
||||
Event::ValuePath(path, key) => self.edit(project, &path, |d| d.value_path(&path, key)),
|
||||
Event::Tab(tab) => {
|
||||
self.tab = tab;
|
||||
replan = false;
|
||||
@@ -360,53 +366,7 @@ impl State {
|
||||
.values()
|
||||
.any(|d| *d == SemanticDecision::Candidate)
|
||||
{
|
||||
let original =
|
||||
Draft::from_project(project, target).ok_or("Target has no editable record.")?;
|
||||
let mut final_value = original.value;
|
||||
let scopes: Vec<_> = self
|
||||
.decisions
|
||||
.iter()
|
||||
.filter(|(_, decision)| **decision == SemanticDecision::Candidate)
|
||||
.filter_map(|(facet, _)| scalar::binding(project, target, facet))
|
||||
.collect();
|
||||
let draft = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Target draft is unavailable.")?
|
||||
.materialize_scopes(&scopes)?;
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
map.insert(source, target);
|
||||
for (facet, decision) in &self.decisions {
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let path = scalar::binding(project, target, facet)
|
||||
.ok_or("Selected property is not applicable to the target.")?;
|
||||
let value = match decision {
|
||||
SemanticDecision::KeepTarget => continue,
|
||||
SemanticDecision::Candidate => draft
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Candidate property is no longer available.")?,
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
scalar::source_value(&fact, self.direction.source_layer(), &map)?
|
||||
}
|
||||
};
|
||||
*final_value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Final target property is unavailable.")? = value;
|
||||
}
|
||||
let final_value = self.combined_value(project, false)?;
|
||||
let candidate = if project.elements.contains_key(&target) {
|
||||
SemanticRecord::Element(
|
||||
serde_json::from_value(final_value)
|
||||
@@ -423,10 +383,74 @@ impl State {
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Compose selected scalar values for both the candidate and dependent form
|
||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||
/// request construction always requires every selected input to parse.
|
||||
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
||||
let trace = self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.ok_or("Choose a trace.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut final_value = Draft::from_project(project, target)
|
||||
.ok_or("Target has no editable record.")?
|
||||
.value;
|
||||
let draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
map.insert(source, target);
|
||||
for (facet, decision) in &self.decisions {
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let path = scalar::binding(project, target, facet)
|
||||
.ok_or("Selected property is not applicable to the target.")?;
|
||||
let value = match decision {
|
||||
SemanticDecision::KeepTarget => continue,
|
||||
SemanticDecision::Candidate => {
|
||||
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
value
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Candidate property is no longer available.")?
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
scalar::source_value(&fact, self.direction.source_layer(), &map)?
|
||||
}
|
||||
};
|
||||
*final_value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Final target property is unavailable.")? = value;
|
||||
}
|
||||
Ok(final_value)
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
let context = self.combined_value(project, true).ok();
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.set_context(context);
|
||||
}
|
||||
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
||||
self.error = Some(
|
||||
"Several traces are available. Choose the correspondence and review history above."
|
||||
|
||||
@@ -58,6 +58,210 @@ fn start(app: &mut App, source: Id) {
|
||||
fn facet(value: DataFacet) -> FacetSelector {
|
||||
FacetSelector::Data(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
|
||||
let (p, oa, sa, _) = fixture();
|
||||
let mut app = App::with_project(p);
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, oa);
|
||||
let minimum = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
let maximum = "/body/Sa/Shared/DataType/definition/Integer/maximum";
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(minimum.into(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMaximum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
assert!(state(&app).can_apply());
|
||||
// Exact event emitted by the upper bound's visible Clear bound button.
|
||||
send(&mut app, Event::Shape(maximum.into(), Value::Null));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[minimum].raw, "-");
|
||||
assert!(
|
||||
state(&app)
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value
|
||||
.pointer(maximum)
|
||||
.unwrap()
|
||||
.is_null()
|
||||
);
|
||||
// Clear must also discard the selected invalid value, without requiring it
|
||||
// to parse before removal. A valid but uncommitted neighboring input survives.
|
||||
send(&mut app, Event::Shape(maximum.into(), json!(30)));
|
||||
send(&mut app, Event::Input(maximum.into(), "29".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Shape(minimum.into(), Value::Null));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[maximum].raw,
|
||||
"29"
|
||||
);
|
||||
assert!(
|
||||
!state(&app)
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs
|
||||
.contains_key(minimum)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
apply(&mut app);
|
||||
assert!(matches!(
|
||||
app.project().elements[&sa].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: Some(29)
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers() {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, layer, "Payload", None)
|
||||
.unwrap();
|
||||
let field = p
|
||||
.add_element(Kind::DataField, layer, "Numbers", Some(item))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Integer,
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: Some(vec![DataValue::Integer(1), DataValue::Integer(2)]),
|
||||
};
|
||||
pair.push(field);
|
||||
}
|
||||
add_trace(&mut p, pair[0], pair[1], "Numbers review");
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, pair[0]);
|
||||
let root = "/body/Sa/Shared/DataField";
|
||||
let minimum = format!("{root}/minimum");
|
||||
let default = format!("{root}/default");
|
||||
let first = format!("{default}/0/Integer");
|
||||
let second = format!("{default}/1/Integer");
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Minimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(minimum.clone(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Minimum),
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Default),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(first.clone(), "-".into()));
|
||||
send(&mut app, Event::Input(second.clone(), "23".into()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Append(default.clone(), json!({"Integer": 3})),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[&second].raw,
|
||||
"23"
|
||||
);
|
||||
// Same-variant reselection must not throw away the invalid authored value.
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(format!("{default}/0"), json!({"Integer": 0})),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
|
||||
send(&mut app, Event::Array(default.clone(), Some(0), 1));
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "23");
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&second].raw, "-");
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Array(default, Some(1), 0));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[&minimum].raw,
|
||||
"-"
|
||||
);
|
||||
apply(&mut app);
|
||||
assert!(
|
||||
matches!(app.project().elements[&pair[1]].shared(), Some(SharedBody::DataField {
|
||||
default: Some(values), minimum: 0, ..
|
||||
}) if values == &vec![DataValue::Integer(23), DataValue::Integer(3)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity() {
|
||||
let mut p = Project::empty();
|
||||
let a = Id::new_v4();
|
||||
let b = Id::new_v4();
|
||||
let first = FacetSelector::Child(KeyPair {
|
||||
oa: Some(a),
|
||||
sa: None,
|
||||
});
|
||||
let second = FacetSelector::Child(KeyPair {
|
||||
oa: Some(b),
|
||||
sa: None,
|
||||
});
|
||||
let first_title = super::view::title(&p, &first);
|
||||
let second_title = super::view::title(&p, &second);
|
||||
assert_ne!(first_title, second_title);
|
||||
assert!(first_title.contains(&a.to_string()));
|
||||
assert!(second_title.contains(&b.to_string()));
|
||||
let one = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
|
||||
.unwrap();
|
||||
let two = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
|
||||
.unwrap();
|
||||
let field = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "Value", Some(one))
|
||||
.unwrap();
|
||||
let annotation = |path| {
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(AnnotationKey { path, field }),
|
||||
sa: None,
|
||||
})
|
||||
};
|
||||
let first_title = super::view::title(&p, &annotation(vec![one, a]));
|
||||
let second_title = super::view::title(&p, &annotation(vec![two, b]));
|
||||
assert_ne!(first_title, second_title);
|
||||
for id in [one, field] {
|
||||
assert!(first_title.contains(&format!("@{}", p.elements[&id].alias)));
|
||||
}
|
||||
assert!(first_title.contains(&a.to_string()));
|
||||
assert!(second_title.contains(&b.to_string()));
|
||||
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
|
||||
}
|
||||
fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
|
||||
send(app, Event::Select(selected.clone()));
|
||||
send(app, Event::Decide(selected, Some(decision)));
|
||||
|
||||
@@ -74,8 +74,12 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
||||
let keys = |keys: &KeyPair<Id>| {
|
||||
format!(
|
||||
"{} ↔ {}",
|
||||
keys.oa.map(|id| project.named(id)).unwrap_or("Absent"),
|
||||
keys.sa.map(|id| project.named(id)).unwrap_or("Absent")
|
||||
keys.oa
|
||||
.map(|id| choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
keys.sa
|
||||
.map(|id| choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into())
|
||||
)
|
||||
};
|
||||
match facet {
|
||||
@@ -93,12 +97,12 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
||||
"Message value · {} ↔ {}",
|
||||
pair.oa
|
||||
.as_ref()
|
||||
.map(|key| project.named(key.field))
|
||||
.unwrap_or("Absent"),
|
||||
.map(|key| annotation_title(project, key))
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
pair.sa
|
||||
.as_ref()
|
||||
.map(|key| project.named(key.field))
|
||||
.unwrap_or("Absent")
|
||||
.map(|key| annotation_title(project, key))
|
||||
.unwrap_or_else(|| "Absent".into())
|
||||
),
|
||||
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
|
||||
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
|
||||
@@ -118,6 +122,14 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -55,22 +55,14 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
]
|
||||
.spacing(10)
|
||||
.width(220),
|
||||
column![
|
||||
text(short(&p.name, 46)).size(17),
|
||||
label(if app.dirty() {
|
||||
"Unsaved changes"
|
||||
} else if app.path.is_some() {
|
||||
"All changes saved"
|
||||
} else {
|
||||
"Local project"
|
||||
})
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
action("New", Message::Request(Pending::New)),
|
||||
crate::hosted::view::header_action(),
|
||||
action("Open", Message::Request(Pending::Open)),
|
||||
action("Save as", Message::Save(true)),
|
||||
button(text("Save all").size(12))
|
||||
button(text(app.save_label()).size(12))
|
||||
.padding([10, 16])
|
||||
.on_press(Message::Save(false))
|
||||
]
|
||||
|
||||
@@ -12,10 +12,10 @@ use tokio::{sync::oneshot, task::JoinHandle};
|
||||
mod data_contracts;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
#[path = "http/semantic_transitions.rs"]
|
||||
mod semantic_transitions;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
|
||||
fn v2_missing_ports() -> Value {
|
||||
let mut old: Value = serde_json::from_slice(include_bytes!(
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
|
||||
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
|
||||
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
|
||||
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
|
||||
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
|
||||
| Automation and hosted editing | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows; optional desktop catalog/open/publish, explicit conditional Save, server review and local Save as; ETag preconditions, bounded requests and atomic current-revision storage |
|
||||
|
||||
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
|
||||
|
||||
@@ -41,18 +41,18 @@ These are concrete design responses, not claims that every Capella user experien
|
||||
| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
|
||||
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
|
||||
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
|
||||
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
|
||||
| UI-coupled automation | Renderer-independent core, documented REST commands/queries and optional hosted desktop editing | Save and server review are explicit; there is no automatic synchronization or merge |
|
||||
|
||||
## Required first-release work
|
||||
|
||||
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
|
||||
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Hosted desktop editing is implemented; its remaining native acceptance is tracked in the release plan and current state. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
|
||||
- Complete Capella communication/scenario variants beyond the specified Studio profile.
|
||||
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
|
||||
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
|
||||
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. Desktop edits and Undo remain local until Save; revision conflicts require explicit review, and Reload is an undoable local operation. Credentials stay in memory and remote desktop connections require HTTPS.
|
||||
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
|
||||
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
|
||||
|
||||
|
||||
+34
-2
@@ -85,9 +85,41 @@ For a native Linux installation, create an `arcadia` service account, install th
|
||||
|
||||
Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blocking tasks run to their completion boundary. The systemd example permits 45 seconds before a supervisor kill; an interrupted process recovers by loading the last installed envelopes. `/health/ready` means the initial store validation succeeded and the process can respond; it is not an ongoing disk-free-space or integrity probe.
|
||||
|
||||
## Desktop and automation integration
|
||||
## Edit hosted models in the desktop
|
||||
|
||||
A desktop or automation client can query current JSON/source, edit with shared typed commands, validate/dry-run, and commit using the original ETag. For hosted editing, keep the user's draft until the service acknowledges the new revision. On `412`, preserve the draft and reload server state for review; a blind replacement retry would discard another client's work.
|
||||
Start a local service from the repository root, then leave it running while using the desktop:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p oa-server --bin arcadia-server
|
||||
```
|
||||
|
||||
The same command works in PowerShell and a Linux shell and uses the platform-local data directory described above. For a custom store, append `-- --data-dir PATH`; choose a local directory outside a synchronized folder. For an installed release, run `.\arcadia-server.exe --data-dir PATH` on Windows or `./arcadia-server --data-dir PATH` on Linux.
|
||||
|
||||
1. In the desktop top bar, choose **Hosted models...**. Enter `http://127.0.0.1:8787` as **Service address**, or your deployed service's HTTPS address. Use the service root, without `/api/v1`; a reverse-proxy path prefix is supported.
|
||||
2. Enter the configured secret in **Bearer token** if required, then choose **Connect / refresh**. Tokens stay in application memory; they are not saved in model/source files. Leave the token empty for an unauthenticated loopback service.
|
||||
3. To share the current local model, choose **Publish as new**. This uploads the complete authored source project, including its entry file, included files and comments, and creates a separate service resource. After acknowledgement, the current project's Save destination becomes that hosted resource; an existing local file is not overwritten.
|
||||
4. To work on an existing resource, search the **Hosted model catalog**, select a model, and choose **Open selected**. **More models** loads another catalog page. Unsaved local work uses the normal save/discard/cancel prompt before opening.
|
||||
5. Edit through diagrams, properties or **Textual model**. Choose **Save to server**, or press Ctrl+S, to validate/apply drafts and send the complete source project. The header shows the last acknowledged hosted revision and whether local changes remain. Edits and Undo/Redo stay local until an explicit Save.
|
||||
|
||||
An invalid source or property draft prevents publication/save and remains available to correct or revert. A rejected server write or failed connection retains the local model and source history. The application does not silently publish the last valid model while dropping an invalid draft. Detached source drafts must be reattached or reverted before hosted Save.
|
||||
|
||||
### Review a newer server version
|
||||
|
||||
An API client or another desktop can edit the same resource. Each Save includes the exact ETag of the desktop's last acknowledged revision. If the server has changed, it rejects that stale Save with `412 revision_conflict`; the desktop retains local work and checks the latest server version for review. Choose **Check server** to perform the same read explicitly, or retry the check after restoring a failed connection.
|
||||
|
||||
The review lists changed, added and removed source files. **View server text** opens a read-only file; **Back to review** returns to the list. Reading the server does not replace local content. **Reload server version** adopts the reviewed bundle as one undoable local change after checking unapplied drafts. Undo can restore the previous accepted local version, but it does not undo the server's revision. There is no automatic merge: inspect and reconcile the intended edits before saving again.
|
||||
|
||||
For a connection failure with an unknown write outcome, use **Check previous write** when offered before another Save. If the server holds exactly the submitted bundle, the desktop acknowledges that save. If it differs, review it. An uncertain publication may already have created a resource; refresh and inspect the catalog before choosing **Publish another copy**.
|
||||
|
||||
### Save locally and manage the connection
|
||||
|
||||
Choose **Save as local...** in Hosted models, or Ctrl+Shift+S, and select an `.arcadia` entry file to retain included files and comments. A successful source-project save switches future Ctrl+S operations to that local destination and leaves the hosted resource unchanged. Choosing JSON exports the model only and keeps the hosted Save destination and unsaved state. Canceling or failing the local save also keeps the hosted destination. **Forget connection** is available after leaving the hosted session and clears the remembered connection/token in memory.
|
||||
|
||||
The desktop accepts HTTPS service URLs and HTTP only for `localhost` or a loopback IP address. Credentials, query parameters and fragments in the URL are rejected; put the bearer token in its own field. Redirects are not followed. For remote access, configure a TLS reverse proxy as described above; the server itself does not provide TLS. To replace an expired token, enter the new value and choose **Connect / refresh** for the same service, then retry the check or Save. Connection settings and tokens are not persisted across application restarts.
|
||||
|
||||
## Automation integration
|
||||
|
||||
Automation clients use the same typed model, validation, authored source and revision preconditions as the desktop. Preserve the submitted draft and original ETag until a write is acknowledged; on `412`, read and reconcile the current version instead of blindly replacing it.
|
||||
|
||||
Use `POST /api/v1/projects/import-source-project` to import `{"entry":"model.arcadia","files":{"model.arcadia":"..."}}`. Use `GET` and conditional `PUT /api/v1/projects/{id}/source-project` to export or edit that complete authored workspace. These routes preserve comments, line endings, entry names and module layout. Missing declaration IDs are inserted once; keep the accepted bundle returned by PUT or subsequent GET. Candidate validation accepts `{"format":"source-project","source_project":{...}}` and reports diagnostics without adoption.
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ when selection changes through Catalog or Layer outside Behavior. Reset only on
|
||||
actual context change; preserve same-view return. Independent review reproduced
|
||||
the leak and native Windows/Linux tests verified the correction in316dc55.
|
||||
|
||||
- Carried-value property browsing follows one exact record scope at a time, with
|
||||
bounded pages and a separate read-only browser location. Flattened representative
|
||||
paths were proven ambiguous in native recursive/reused data. Browsing does not
|
||||
change the selected field or expression; choosing a field remains an explicit
|
||||
draft edit through the ordinary validated command. No persistent browser schema.
|
||||
## Semantic review proposals and historical admission (2026-09-06)
|
||||
|
||||
R3 stores closed, typed, independently reviewed OA/SA facet pairs. Constructor
|
||||
|
||||
+7
-6
@@ -27,12 +27,13 @@ forms, folding, placement and correct camera/tool lifetime. Windows default/mini
|
||||
and Linux native acceptance plus independent reviews are recorded in
|
||||
release-state-editing.md. Direct scenario/process gestures remain required.
|
||||
|
||||
R6 data contracts are active in the isolated data integration checkout. Freeze the
|
||||
full payload/dictionary shape as schema/source4, keep existing typed ports, migrate
|
||||
older formats explicitly while preserving lexical ownership/comments, then verify
|
||||
GUI/text/REST parity and real engineering workflows. See release-data-contract.md
|
||||
and the implementation plan in that checkout. R3 semantic drift/reconciliation
|
||||
follows the R6 freeze; its current prose-only behavior is insufficient.
|
||||
R6 data contracts and direct scenario/process gestures are integrated; native
|
||||
acceptance continues on both platforms. Typed R3 core/source5/REST and scalar review
|
||||
milestones1–2 are integrated and pass full platform checks. Close independent scalar
|
||||
fixes and native review, then finish constructor/keyed/order/reset/artifact and
|
||||
multi-source graphical authoring using the existing shared core planner. Source5
|
||||
migration retains lexical ownership/comments; no flat source rewrite is allowed.
|
||||
See release-data-contract.md, release-semantic-integration.md and release-semantic-gui.md.
|
||||
|
||||
Continue source authoring improvements only through the production parser/updater.
|
||||
The23-test experiment is evidence, not architecture: independent probes exposed
|
||||
|
||||
+125
-201
@@ -2,227 +2,151 @@
|
||||
|
||||
## Objective and phase
|
||||
|
||||
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
|
||||
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
|
||||
textual and REST semantics, artifact traceability, directly editable behavior,
|
||||
offline guidance, and Windows/Linux release evidence. Later Arcadia layers remain
|
||||
future releases. The full authoritative objective was reread on 2026-09-06:
|
||||
attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
|
||||
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
|
||||
The first-release goal is active and incomplete: usable Rust/Iced OA/SA,
|
||||
equivalent graphical/source/REST semantics, traceability, direct behavior editing,
|
||||
offline guidance, lifecycle/hosted workflows and Windows/Linux evidence.
|
||||
Authoritative objective reread 2026-09-06: attachment10134df9-bea2-4c02-a820-67b53d6b86f7.
|
||||
PLAN retains full scope; no subsystem checkpoint completes the release.
|
||||
|
||||
Direct state-machine editing is accepted through production316dc55. The main
|
||||
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
|
||||
being implemented in a separate checkout; they are not accepted or released.
|
||||
Meaningful implementation and runtime progress continues; no external blocker exists.
|
||||
Main remains a documentation pointer on production a8f451e. Current integration is
|
||||
D:/CodexBuilds/Capella2-data-integration, branch codex/data-contract-integration.
|
||||
Production merge332e46b + formatc6db9d4 + evidenced114a5d integrate DATA/scenario/
|
||||
process60d162c and semantic6ad3cb. Scalar fixes5a51a75 integrated as e7fa061 and evidence767634a;
|
||||
322 desktop tests/strict checks/build pass on both platforms. That worker owns DATA/Linux indexes and targets;
|
||||
root owns this file, other native ledgers, README and all native input.
|
||||
|
||||
## Accepted foundations
|
||||
## Accepted foundations and evidence
|
||||
|
||||
- Core typed OA/SA semantics, validated atomic commands and ordinary history are
|
||||
shared by three Rust crates. Typed ports, explicit unresolved migration and
|
||||
original Capella observations: release-port-{contract,core,migration}.md and
|
||||
- Typed ports and migration: release-port-{contract,core,migration}.md and
|
||||
release-capella-workflow-benchmark.md.
|
||||
- Explorer/port discovery d364d3c: Add port on participants, grouped roots, focused
|
||||
children, breadcrumbs, contextual paging/search and full-depth outline. Actual
|
||||
Windows creation,251-root/12-level trials,1100x700 and Linux reopen/font fixes:
|
||||
release-explorer-usability.md.
|
||||
- Routing23535b4, viewport366ce44, labels65fa9ab: authored bends/labels, group
|
||||
movement, fold restoration, Undo/Redo, save/reopen and SVG, including minimum-size
|
||||
native trials. Dense full solve measured119ms; arbitrary crossings remain possible.
|
||||
See release-routing-verification.md and release-routing-linux.md.
|
||||
- Source4e8575b: nested lexical ownership/includes, provenance-aware token writes,
|
||||
exact accepted source/model history, retained drafts, Save All/recovery and coupled
|
||||
hosted source/model revisions. Windows/Linux native and actual REST restart,
|
||||
stale ETag, comment-only edit and cross-file reparent passed. Full422 Windows /
|
||||
425 Linux tests at that checkpoint. See release-source-integration.md.
|
||||
- Direct drawing fef2e0d plus tooltip ad7feb5: OA Interaction, SA Functional and
|
||||
Boundary Exchange by palette click-click or drag; compatible highlights, automatic
|
||||
function ports, four-side draggable anchors, contextual menus and source folders.
|
||||
Independent pointer/tooltip findings fixed and native retested. Windows193 desktop
|
||||
and Linux189+4 focused tests followed earlier full485/488 suites. Scoped native
|
||||
Windows/Linux acceptance: release-port-drawing.md and release-context-menus.md.
|
||||
- Explorer grouping/deep hierarchy, contextual port creation, routing, labels,
|
||||
anchors, direct exchange drawing, menus and source tree: release-explorer-usability.md,
|
||||
release-routing-{verification,linux}.md, release-port-drawing.md,
|
||||
release-context-menus.md.
|
||||
- Modular source/includes, provenance-aware writes, exact source/model history,
|
||||
retained drafts and coupled hosted revisions: release-source-integration.md.
|
||||
- Direct state creation/movement/transitions/reconnect, typed forms, fold and
|
||||
camera/tool lifetime through316dc55, Windows/Linux native: release-state-editing.md.
|
||||
|
||||
## Newly accepted state workspace
|
||||
## Integrated semantics and verification
|
||||
|
||||
Production316dc55 includes typed machine views, shared persisted geometry, atomic
|
||||
region/symbol/state creation, group movement, direct transitions and reconnection,
|
||||
Internal rows, Local edges, typed detail forms, folding, placement and navigation.
|
||||
Architecture and Behavior view selections are separate. Drafts survive rejected
|
||||
source/model commits; passive camera events never apply a form.
|
||||
R6 data payloads/defaults/inheritance/associations/interface allocations/qualified
|
||||
message-value paths are integrated. Source3 migration preserves comments/nesting
|
||||
until Save All. Typed review now uses source/schema5 with explicit migration.
|
||||
See release-data-{contract,implementation,verification}.md.
|
||||
|
||||
Independent/native findings fixed: covered GPU shapes, pseudo-label/edge collision,
|
||||
selection-relayout drag displacement, missing typed form access, folded-content
|
||||
access, wrong-view placement Apply, and camera/tool leakage on machine navigation.
|
||||
An actual owner/view change resets gesture state and fits; returning to the same
|
||||
view preserves camera/tool. Exact evidence and limitations: release-state-editing.md.
|
||||
Semantic core supports finite typed facts, historical references, mappings/reset,
|
||||
constructor groups, canonical agreement and exact prepared impacts. Independent
|
||||
equal-discriminator review found and fixed first acknowledgment reachability:
|
||||
initial equal unreviewed discriminators acknowledge only that scalar; true and
|
||||
historical switches retain grouped rules.353 core tests and independent36 focused/
|
||||
8 adversarial probes passed. Normative clarification6ad3cb is integrated.
|
||||
|
||||
Verification: full551 Windows /554 Linux workspace tests and strict workspace
|
||||
Clippy at e0b2210. Final316dc55:241 desktop tests, strict all-target desktop Clippy,
|
||||
and desktop builds on each platform; five new navigation tests included. Two guide
|
||||
tests passed after help changes. Source/reST/API state contracts and actual HTTP
|
||||
rejection/revision/source/restart tests passed earlier in the integration.
|
||||
REST query/preview/apply is implemented and verified after explicit user approval.
|
||||
Exact prepared candidates preserve generated identities, conditional ETags and
|
||||
coupled model/source commits. Fingerprints express consistency, not authentication.
|
||||
48 server tests pass; optimized live loopback checks validate184 schemas and19
|
||||
actual/typed values, reject9 malformed shapes. Strict reST/catalog checks pass.
|
||||
No deployment was requested or performed. See release-semantic-integration.md.
|
||||
|
||||
Root and independent Windows native trials covered1440x900 and1100x700 creation,
|
||||
nested movement, first-drag transition, endpoint/menu reconnect, invalid target,
|
||||
Escape, timed Internal, Local/EntryPoint, placement Apply, Undo/Redo and save/reopen.
|
||||
Linux native covered1440x900 creation, first transition/move, Undo/Redo, fold and
|
||||
saved collapsed reopen. Final316dc55 native on both platforms proved same-view
|
||||
camera/tool preservation and different-machine Fit/Select reset. Logs are empty.
|
||||
d114a5d integration: Windows718 / Linux721 tests pass, one intentional inheritance
|
||||
benchmark ignored on each. Strict workspace all-target Clippy, formatting and
|
||||
optimized desktop/server builds pass. Linux exec80687 exited0; logs are
|
||||
semantic-workspace-test.log, semantic-workspace-clippy.log and
|
||||
semantic-release-build.log in Capella2-data-linux-target.
|
||||
|
||||
## Active R6 implementation and next actions
|
||||
Scalar GUI milestones1–2 provide existing-trace scoped Keep/Take/Candidate review,
|
||||
typed forms, exact ordinary Batch, impact review and source/history. Independent
|
||||
review found three P2 issues: unrelated invalid draft blocking scoped actions,
|
||||
combined type-decision form context, and hidden historical keyed identities.
|
||||
Worker5a51a75 fixes all three;277 desktop tests/Clippy/build pass in SEM. Fresh
|
||||
independent recheck passed280 tests (277 repository +3 independent probes),
|
||||
closing all three P2 findings; integrated platform verification also passed. Native scalar
|
||||
acceptance and structured milestone3 are still required; do not claim full parity.
|
||||
|
||||
Checkout D:/CodexBuilds/Capella2-data-integration; branch
|
||||
codex/data-contract-integration, base5ce3eef+plan2bf4c54+navigation9118218.
|
||||
Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/source4
|
||||
with full data payload and dictionary slices together; existing typed ports remain.
|
||||
See release-data-implementation.md and release-data-contract.md in that checkout.
|
||||
## Native behavior evidence and current environment
|
||||
|
||||
Core is committed0b58107; source/migration1230ab8. Root API/docs are integrated
|
||||
on the same working tree; desktop remains in flight and R6 is not accepted.
|
||||
Core15 R6 +28workflow tests passed again after final allocation-arrow suppression.
|
||||
Linux0b58107 full core307 tests passed (one explicit probe ignored by default),
|
||||
with strict all-target core Clippy. Earlier13ports/14scenarios and115 source/lib
|
||||
tests also passed. Desktop final257 tests and strict all-target Clippy pass;
|
||||
optimized Windows desktop build b403039 passed. Its SHA256 is recorded in the R6 verification note; native acceptance remains pending.
|
||||
Frozen60d162c binaries live under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||
ui-behavior-windows-60d162c and ui-behavior-linux-60d162c. Hashes in the linked ledgers.
|
||||
Windows prior scenario native passed middle/ALT insertion, guarded correlated
|
||||
Call/Reply, qualified values, Undo/Redo, save/reopen, minimum layout and viewport
|
||||
retention. See release-scenario-editing.md. Remaining advanced tasks are explicit.
|
||||
|
||||
Root41 server tests passed; a fourth R6 HTTP workflow now also passes, alongside all4 data HTTP tests and strict server Clippy. The original3 actual HTTP workflows for the published
|
||||
data example, protocol/artifact identity, atomic rejected defaults, query/export,
|
||||
reidentify/delete/restart, source3 bundle migration reports and old coupled3
|
||||
envelope upgrade without write/revision. Tests exposed dropped bundle reports and
|
||||
rejected legitimate old source/model pairs; both root fixes passed retest.
|
||||
Strict reST HTML generation and81 OpenAPI schema definitions passed. Actual frozen
|
||||
server32033FFE imported the published example; its complete Project/StoredProject
|
||||
responses passed served/current JSON Schema validation and obsolete shapes rejected.
|
||||
Evidence: api-r6-contract and r6-server-tests-after.log under the release directory.
|
||||
Linux1440x900 Wayland native: numeric Retry budget0..4 rejects default9 without
|
||||
losing draft; default2 saves, Undo/Redo works. Recursive Destination/Next/Latitude
|
||||
browser preserves independent Origin expression; save/reopen passed. Created
|
||||
Validated route Call/Reply in the middle, Undo/Redo and save/reopen passed. Created
|
||||
SA chain with two existing functions, drew guarded ready link, inserted existing
|
||||
Navigation request into it, verified guard and original link ID, Undo/Redo and saved
|
||||
69objects/7links/3expected checks. Final chain reopen and advanced process cases
|
||||
remain. See release-data-verification.md and release-process-editing.md.
|
||||
|
||||
Independent core review found association transition closure pulling unrelated
|
||||
types and missing empty-enum warning; both corrected and focused tests passed.
|
||||
Repeated per-ancestor full scans were simplified to one scan per helper call.
|
||||
Explicit optimized inheritance timings250/500/1000: Windows21.070/89.088/391.590ms,
|
||||
Linux22.381/111.662/408.129ms. These single-run empty-chain fixtures measure core
|
||||
validation, not general UI latency. A1000-deep chain remains a pathological cost.
|
||||
Wayland exec61870 closed normally, exit0 and no serious runtime output. Current Windows semantic trial is exec62786/window2099614, frozen e7fa061,
|
||||
1100x700. Root owns input. Re-inventory; never reuse closed window4852212.
|
||||
Original Capella395654 remains available. Do not modify protected user windows.
|
||||
Wayland compact resizing is unverified: capture separates frame and client into
|
||||
bounded surfaces. X11 launch fails in dependency xkbcommon-dl because Ubuntu lacks
|
||||
libxkbcommon-x11-0. sudo installation requires the user's password; user has been
|
||||
given `sudo apt-get install libxkbcommon-x11-0`. Windows/Wayland testing continues.
|
||||
Do not bypass authentication or treat this optional environment issue as goal blocked.
|
||||
|
||||
Independent desktop review fixes now pass tests: a focused dictionary with24-row
|
||||
pages replaces unbounded inherited rows; recursive paths support explicit drill-down
|
||||
and imported finite paths; same-phase styles/same-exchange reselection preserve
|
||||
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
|
||||
only on actual layer/document changes; its regression failed before and passes now.
|
||||
Root found another unresolved scale issue: scenario creation still enumerates every
|
||||
carried field into both a dropdown and individual input rows. A bounded focused value editor is now implemented and under automated/independent review; actual native operation remains required before R6 acceptance.
|
||||
## Exact next actions
|
||||
|
||||
Current ownership: drawing_correctness owns desktop except root-owned guide.rs and
|
||||
the dedicated Windows Cargo target. state_architecture_review independently reviews
|
||||
R6 spec/API consistency; its data_view.rs ownership has ended. data_core runs an
|
||||
isolated R3 typed-baseline experiment; Linux R6 core checkout is idle and verified.
|
||||
Root owns API/OpenAPI/reST, docs, integration and native input. No concurrent writers.
|
||||
1. Finish5a51a75 integration/recheck, update immutable binaries/hashes, operate scalar
|
||||
review in actual Windows/Linux app including reverse, failure and compact forms.
|
||||
2. Complete native R6/scenario/process cases (both layers, branching/reuse/reconnect,
|
||||
cancellation, minimum size and source reopen); fix from observed behavior.
|
||||
3. Complete structured semantic GUI: constructor groups, keyed/order/reset/artifact
|
||||
operations and multi-source final-candidate review; retain shared core planner.
|
||||
4. Finish coverage/filter, Home/recent/recovery, hosted desktop ETag and offline help
|
||||
requirements. Fresh end-state reviews, full platform/MSRV checks and packaging.
|
||||
|
||||
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
|
||||
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
|
||||
scenario-value authoring, then full integrated Windows/Linux verification.
|
||||
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
|
||||
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
|
||||
3. Integrate the verified R3 core/source checkpoint from the isolated semantic-review
|
||||
checkout, then complete desktop/server and native/platform acceptance. The closed
|
||||
typed per-facet representation (C) and its immutable-proposal rule are recorded
|
||||
in release-semantic-review-implementation.md; this is not release acceptance.
|
||||
4. Complete direct scenario/process gestures, persistent coverage/filters,
|
||||
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
|
||||
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
|
||||
Windows/Linux/MSRV checks, real runtime, reproducible docs and release packaging.
|
||||
Use dedicated Cargo targets. Windows: cargo test --locked --offline --workspace;
|
||||
cargo clippy --locked --offline --workspace --all-targets -- -D warnings;
|
||||
cargo build --locked --offline --release -p oa-desktop -p oa-server.
|
||||
WSL Ubuntu24.04 uses target/linux-cargo.toml, Rust1.98.1 at
|
||||
/home/sorokinoleg/.cache/arcadia/rust/bin and Mesa software Vulkan. No remote push,
|
||||
publication, usage reset or broad recursive target deletion is authorized.
|
||||
|
||||
## Environment and native ownership
|
||||
## Latest native and process checkpoint — 2026-09-06
|
||||
|
||||
R3 is authorized in D:/CodexBuilds/Capella2-semantic-review, branch
|
||||
codex/semantic-review, baseab661f2, dedicated target Capella2-semantic-target.
|
||||
Core owns typed facets/catalog/drift/mapped review and atomic preparation; source
|
||||
worker owns schema/source5 compatibility and language integration. Both preserve
|
||||
data4 and the existing Command boundary. The 11-case C proof and independent
|
||||
critic justified direct typed facts instead of carrier masks/order shadows; more
|
||||
adapter code is accepted for fewer persisted invariants. This is an active
|
||||
implementation checkpoint, not R3 acceptance. Root owns concurrent R6 native work.
|
||||
Process cf33785 + evidence1b0b072 fixes forced zoom on palette changes and canvas
|
||||
remount/scroll loss on pending forms, with persistent two-path field labels.
|
||||
325 desktop tests/strict checks/release pass on each platform. Independent24-test
|
||||
review closed both original defects but found owner-to-owner Scrollable leakage.
|
||||
Fix8c60008 resets actual observer child only on (document,owner) changes;25 focused
|
||||
red/green checks pass. Full platforms and fresh semantic_http_review are active.
|
||||
semantic_integration owns DATA/Linux indexes and targets for this narrow follow-up.
|
||||
|
||||
## R3 core/source frozen checkpoint (2026-09-06)
|
||||
Windows60d162c reopened Linux69-object bundle. At1100x700, inserted named Choice
|
||||
Route available? on the ready path, then Two paths added Choice/two existing-work
|
||||
occurrences/Merge with ready/else guards. Undo74→70 and Redo70→74; Save All wrote
|
||||
74objects/13links/5 incomplete-model checks. Original link identity/ready preserved.
|
||||
Trial3607004 closed; saved bundle remains in ui-behavior-linux-60d162c/model.
|
||||
Final camera/owner fixes and advanced OA/process native cases remain unverified.
|
||||
|
||||
The isolated semantic-review tree now contains the complete 84-scalar/13-family
|
||||
catalog, keyed member/order/annotation/binding/child/incident/artifact facts,
|
||||
bounded linked-definition drift, typed historical reference/remap/admission, and
|
||||
bidirectional mapped review through one ordinary prepared Command batch. Source
|
||||
and strict schema1–4→5 compatibility are committed in ab960cb; this core checkpoint
|
||||
adds the closed review implementation. Desktop/API integration and Windows/Linux
|
||||
native/platform acceptance remain root-owned work. No native input was used here.
|
||||
Scalar Windows e7fa061 actual native: source5 fixture, numeric prior/current
|
||||
comparison; Keep SA minimum1, Take OA maximum20, candidate default99 rejects and
|
||||
retains all three decisions; corrected7. Error, editor and Apply fit1100x700.
|
||||
Additional changes0; Apply atomically changes SA to1..20/default7, OA remains
|
||||
0..20/default5. Native text verifies comment and values. Undo restores saved state,
|
||||
Redo restores review; Save All succeeds. Field Type Take OA Boolean + Default
|
||||
Candidate creates Boolean(false), previews/applies/saves successfully at1100x700.
|
||||
Current saved working copy: C:/Users/sorok/AppData/Local/Temp/
|
||||
capella2-semantic-native-fixture-20260906/windows-scalar.arcadia. Runtime stderr empty.
|
||||
Reverse, mapping, historical identity, reopen and Linux scalar cases remain.
|
||||
|
||||
Every source derives proposals from one immutable initial project. Reset old
|
||||
selectors before scope derivation, compare the union of selected canonical target
|
||||
slots (including constructor removals), materialize once, then audit every desired
|
||||
value before and after command preparation. Same-target constructor reconciliation
|
||||
preserves target-only members; induced cleanup needs exact explicit review and
|
||||
cannot override an independently selected Keep/Take. Historical facts may outlive
|
||||
former record families/deleted UUIDs; new resolutions still require current family
|
||||
and leaf applicability. DECISIONS records why these are distinct admission rules.
|
||||
semantic_gui_design owns SEM message-kind/TimerExpression constructor slice and
|
||||
compact Traceability toolbar clipping fix (views.rs), plus its semantic module/forms
|
||||
adapter/GUI ledger and semantic-desktop-target. Scope is one existing trace and a
|
||||
complete nested constructor family; no API/core change or full parity claim.
|
||||
semantic_http_review temporarily owns semantic-desktop-target for isolated process
|
||||
review; GUI worker must wait for release before builds. Root owns all native input.
|
||||
|
||||
Independent review's original five findings and four residuals are covered by these
|
||||
passing cases in crates/oa-core/tests/r3_semantic.rs:
|
||||
|
||||
| Finding | Closure regression |
|
||||
| --- | --- |
|
||||
| Repeated unequal constructor Keep hides bound drift | repeated_keep_of_unequal_constructor_pair_cannot_swallow_bound_drift |
|
||||
| StateKind change fails legacy mapping preflight | changed_state_kind_remains_reviewable_in_both_directions |
|
||||
| Artifact metadata writes collide through different owner traces | artifact_metadata_is_attached_and_cannot_last_write_through_two_owner_traces |
|
||||
| Constructor retirement overrides another source's Keep | constructor_retirement_cannot_override_other_sources_kept_bound_in_either_order |
|
||||
| Partial review falsely rolls up as unchanged | semantic_rollup_does_not_claim_unchanged_when_only_unit_is_reviewed |
|
||||
| Another source's introduced member escapes constructor removal agreement | constructor_union_conflicts_with_another_sources_new_member_in_either_order |
|
||||
| Source constructor history causes target-only member loss | same_target_constructor_preserves_local_members_and_reset_precedes_expansion |
|
||||
| Reset old mapping selector reappears during group expansion | same_target_constructor_preserves_local_members_and_reset_precedes_expansion |
|
||||
| Artifact Owner cannot explicitly move the attached link | artifact_owner_candidate_moves_link_and_conflicts_with_kept_attachment |
|
||||
|
||||
The additional combined_ordered_membership_commutes_or_requires_agreed_anchors
|
||||
case verifies both request orders: independent removals preserve the local member;
|
||||
contradictory inferred insertion anchors reject. Coverage includes atomic rollback,
|
||||
one undo/redo, stale plans, no-op replanning, explicit cleanup acknowledgement,
|
||||
partial Keep/Take, qualified rows, mapping resets and historical deleted refs.
|
||||
|
||||
Final bounded follow-up extends those same Owner/reset findings:
|
||||
target_only_artifact_move_records_owner_and_metadata_in_either_order covers an
|
||||
unpaired target link, Owner alone and Owner+Name in either order, actual resulting
|
||||
typed owner history, and rejected future metadata review through the former owner.
|
||||
retired_source_branch_retains_historical_member_pair_in_both_directions preserves
|
||||
the established pair after a branch disappears instead of adding a colliding
|
||||
one-sided projection. The opposite appearance case still needs an explicit reset.
|
||||
The independent read-only re-review found no remaining concrete defect within the
|
||||
nine findings and these two subsets, including the immutable proposal/union/audit
|
||||
structure. That is scoped core review closure, not UI/API/spec release acceptance.
|
||||
|
||||
Final Windows core evidence in D:/CodexBuilds/Capella2-semantic-review:
|
||||
|
||||
```text
|
||||
cargo test --package oa-core --all-targets --locked --offline --config target/semantic/cargo.toml --target-dir D:/CodexBuilds/Capella2-semantic-target --no-fail-fast
|
||||
cargo clippy --package oa-core --all-targets --locked --offline --config target/semantic/cargo.toml --target-dir D:/CodexBuilds/Capella2-semantic-target -- -D warnings
|
||||
```
|
||||
|
||||
Both exit0: 345 passed, 0 failed, 1 intentionally ignored inheritance scaling
|
||||
probe across 33 test executables; strict all-target Clippy has no warnings.
|
||||
Logs: target/semantic/core-final-all-tests.log and core-final-clippy.log. Focused
|
||||
immutable-proposals-artifact-focus.log contains 28 semantic and 7 source tests; the
|
||||
source suite includes real prepared writeback and included-file save/reopen. The
|
||||
catalog coverage unit checks every current model payload field's closed capture
|
||||
and write scope. These logs use the distinct semantic-target and vendored config.
|
||||
|
||||
Native input remains paused following the user's physical stop. Root must handle
|
||||
resumed native authorization and integrated acceptance; the broader goal is active.
|
||||
|
||||
Native input is paused after physical Escape; no R6 native trial has run.
|
||||
Root exclusively owns native input. Latest saved Windows trial is window1706782,
|
||||
ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning.
|
||||
Linux navigation trial3148598 closed normally, exec46107 exit0; baseline Linux
|
||||
trial721472 also closed normally, exec72680 exit0. Independent Windows3672536 is
|
||||
saved at1100x700. Older root drawing windows are saved. Re-inventory before input.
|
||||
Protected user7210312/6228978 were absent; do not reset/save/close if they return.
|
||||
|
||||
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
|
||||
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
|
||||
D:/CodexBuilds/Capella2-state-linux-review and -target. Use distinct Cargo targets
|
||||
per checkout; shared-cache results were previously caught and excluded.
|
||||
|
||||
Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
|
||||
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
|
||||
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
|
||||
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
|
||||
Latest process closure: DATA/Linux f4cb486, production8c60008.327 desktop tests,
|
||||
strict Clippy/fmt/build pass on both. Independent26 tests (4 actual Iced observer,
|
||||
22 process) close owner-scroll findings. Both targets are idle and root-owned.
|
||||
Frozen ui-process-owner-{windows,linux}-8c60008 await root native verification.
|
||||
semantic_http_review used a separate Temp target and released it; SEM GUI owns
|
||||
semantic-desktop-target exclusively. Hosted lifecycle mapping is read-only parallel
|
||||
work by semantic_integration; no shared index or target reservation for that task.
|
||||
|
||||
+39
-10
@@ -37,11 +37,17 @@ requires native workflows, semantic evaluators and fresh independent review.
|
||||
- Ordinary Document commands atomically synchronize source/model; failed writes
|
||||
retain drafts. Source comments require occurrence/provenance-aware edits, not
|
||||
whole-model equality or flat reformatting. Independent counterexamples were fixed.
|
||||
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
||||
- Accepted-main R3 existing-target reconciliation remains name/description only;
|
||||
isolated typed core/source/API are implemented, with desktop integration active. R6 data shape
|
||||
expansion is active implementation, not accepted functionality.
|
||||
- R6 core/source and actual HTTP semantics pass focused Windows and full Linux
|
||||
core checks; desktop257 tests pass after independent draft/path/paging fixes.
|
||||
Native R6 acceptance and bounded scenario-value authoring remain outstanding.
|
||||
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
|
||||
strict Clippy/release passed on both platforms. Native Windows data editing,
|
||||
typed defaults, allocations, recursive qualified Call/Reply values, history and
|
||||
saved reopening passed. Source3 migration now preserves comments and nesting.
|
||||
Native found flat property browsing still ambiguous; one-record browsing with
|
||||
bounded pages passes264 desktop tests/Clippy/release on both platforms and native
|
||||
Windows compact/normal browsing, expression retention and saved reopen.
|
||||
Linux native R6 acceptance remains outstanding.
|
||||
|
||||
## Assumptions and open questions
|
||||
|
||||
@@ -51,11 +57,11 @@ requires native workflows, semantic evaluators and fresh independent review.
|
||||
gestures still need their own occurrence/order semantics and task verification.
|
||||
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
|
||||
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
|
||||
- R3 typed historical bodies plus finite proposals appear simpler than a separate
|
||||
recursive semantic-value schema. An isolated probe supports scalar partial review
|
||||
and deleted historical references, but ordered membership/order acknowledgements
|
||||
need separate typed order checkpoints. Historical source reference parsing is
|
||||
still unimplemented; this remains a candidate awaiting independent review.
|
||||
- R3 selected design C uses finite self-tagged typed facets, separate membership
|
||||
and order acknowledgements, and transient constructor groups. Source5 historical
|
||||
references are implemented in the isolated semantic checkout. Independent review
|
||||
found narrow-acknowledgement/canonical-agreement/rollup defects; the worker has
|
||||
added regressions and fixes. Full catalog, GUI/API/spec and acceptance remain.
|
||||
|
||||
## Decisions and current strategy
|
||||
|
||||
@@ -68,4 +74,27 @@ Finish and independently verify R6, then semantic R3 reconciliation and direct
|
||||
scenario/process gestures. Complete lifecycle/recovery, hosted editing, guidance
|
||||
and fresh cross-disciplinary Windows/Linux acceptance. No subsystem checkpoint
|
||||
completes the first release. Current work is making measured implementation,
|
||||
review and runtime progress; no external blocker exists.
|
||||
review and runtime progress. User reauthorized Computer Use. Root resumed combined Windows scenario tests;
|
||||
middle insertion, history, save, reorder and scroll/Escape cancellation passed.
|
||||
Three native UI findings are being refined; Linux acceptance remains pending.
|
||||
|
||||
|
||||
Latest verified facts: integrated60d162c passes306 desktop tests/strict Clippy/build
|
||||
on Windows and Linux. Windows ALT/pinned-actions/viewport retention native passed;
|
||||
Linux native is active. RESTd130013 passed48 server tests plus served/local schema
|
||||
validation and independent review after direct user confirmation resolved approval.
|
||||
New constructor-acknowledgment reachability defect was independently reproduced;
|
||||
narrow core fix and separate scalar GUI implementation are active in SEM. These
|
||||
are current unresolved work, not accepted release limitations. See current STATE.
|
||||
|
||||
## Current evidence superseding earlier in-progress notes — 2026-09-06
|
||||
|
||||
Semantic source5/core/REST and scalar GUI are integrated at d114a5d. Full Windows718/
|
||||
Linux721 tests, strict Clippy, optimized builds and live semantic REST contracts pass.
|
||||
The equal initial discriminator acknowledgment defect is fixed and independently
|
||||
closed. Three scalar GUI P2 fixes5a51a75 are under fresh recheck/integration; structured
|
||||
authoring remains required. Basic Linux R6, middle scenario and guarded chain insertion
|
||||
have actual native evidence in their ledgers; advanced and compact cases remain.
|
||||
X11 lacks libxkbcommon-x11-0, installation requires user's sudo password. User has
|
||||
the exact fix command; Windows/Wayland progress is independent. This environment
|
||||
issue is not evidence of a semantic defect or reason to mark the release blocked.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Constructor history correction
|
||||
|
||||
Scope: `oa-core` only, based on HOST `f6a59de85b815fbea5ec578bb79d0a15819c42b4`. No desktop, source grammar, API shape, or OA port semantics changed by the keyed closure correction.
|
||||
|
||||
## Reproduction and decision
|
||||
|
||||
The original independent Boolean-to-Enumeration probe passes OA to SA on an unmodified HOST core. Extending the same fixture to SA to OA reproduces the duplicate `Literal(incoming,incoming)` conflict. New production-core regressions reproduce both the literal and `Event.Signal` member failures before the implementation change; `constructor-closure-red.log` records two failures. This is directional catalog projection, not UUID ordering: a live SA-only selector survives alongside the newly paired selector after a constructor copy.
|
||||
|
||||
Constructor closure now removes that redundant transient one-sided projection when a pair covers it. Established historical one-sided selectors remain, so changing their correspondence still requires an explicit reset. Both-sided historical pairs and removed branch observations retain their existing rules.
|
||||
|
||||
Suppressing the duplicate exposed another reverse-direction defect: target-local literals were lost because their implicit canonical key looked paired even when the source had no member. Preservation now uses observed membership (`true` or an optional row containing a value), with the existing prior source-membership guard preventing resurrection of reviewed removals. This implements the existing language.rst requirement to preserve target-only members when a historical switch reaches the target's current constructor; no normative contract was relaxed.
|
||||
|
||||
The intermediate normalization-only run (`constructor-closure-normalized.log`) passes Signal pairing but fails reverse literal preservation. Three final regressions cover both directions, no-history paired Signal insertion, established one-sided history requiring reset, local literal retention, and previously corresponding removed members staying removed. A separate read-only review checked selector normalization and presence semantics; its deletion-guard test recommendation was implemented.
|
||||
|
||||
## Verification
|
||||
|
||||
Dedicated target: `D:/CodexBuilds/Capella2-scenario-target`.
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||
cargo test --locked --offline -p oa-core --test r3_semantic
|
||||
cargo test --locked --offline -p oa-core
|
||||
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo run --offline --manifest-path C:/Users/sorok/AppData/Local/Temp/capella2-constructor-literal-host-probe/Cargo.toml
|
||||
```
|
||||
|
||||
All 31 semantic tests and all 356 Windows core tests pass. Strict core all-target Clippy and workspace formatting pass. The independently adapted probe passes both directions against the final implementation (`constructor-independent-green.log`). Full logs: `constructor-core-test.log`, `constructor-core-clippy.log`, `constructor-r3-semantic-green.log` in the dedicated target. Actual GUI/API acceptance and Linux integration remain with the root engineer; this core-only milestone makes no native acceptance claim.
|
||||
|
||||
|
||||
## Separate follow-up: review an existing OA Legacy port
|
||||
|
||||
The shared transition planner previously removed every typed SA port before resolving explicit existing-target mappings. Consequently an existing trace could not Keep or supply a Legacy Candidate in SA-to-OA semantic review: it failed with `Review source is outside selected transition mappings`. Two focused regressions reproduced that boundary on the committed keyed correction (`existing-port-red.log`).
|
||||
|
||||
The language contract permits same-kind Port traces and reviewed updates from either layer, while retaining OA Legacy-only compatibility. The planner now retains only explicitly mapped SA ports whose selected existing OA target is Legacy. It does not synthesize a target, change a port contract, or un-omit dependent SA implementation records. Structural validation still rejects typed OA Take/Candidate proposals. Automatic/new-counterpart omissions remain unchanged.
|
||||
|
||||
Three regressions exercise Keep and Candidate retaining Legacy, unchanged SA source, typed history source/JSON round trips, typed OA proposals failing without mutation, and an explicit new Port counterpart remaining an SA-only omission. A separate read-only review found no scope/invariant defect. Focused red-to-green result: all three pass (`existing-port-green.log`). This change uses the common planner consumed by desktop and REST; it adds no endpoint or transport-specific behavior.
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||
cargo test --locked --offline -p oa-core --test r3_semantic reverse_ -- --nocapture
|
||||
cargo test --locked --offline -p oa-core
|
||||
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
```
|
||||
|
||||
All 359 Windows core tests pass, including all 34 semantic tests and existing portless-OA transition coverage. Strict core all-target Clippy and workspace formatting pass. Logs: `existing-port-core-test.log` and `existing-port-core-clippy.log` in the dedicated target. REST preview uses the corrected core planner at `oa-server/src/routes.rs` and commit uses the shared core prepare path; those handlers were inspected, not changed. GUI constructor tests and actual API/native acceptance remain with root integration; no native input or desktop code was changed here.
|
||||
@@ -1,50 +1,152 @@
|
||||
# R6 data contracts — verification in progress
|
||||
# R6 data contracts — native verification in progress
|
||||
|
||||
R6 is not accepted. Main remains at state-editing acceptance a8f451e. The updated
|
||||
objective10134df9-bea2-4c02-a820-67b53d6b86f7 was read fully on2026-09-06; product
|
||||
scope is unchanged and reasoning effort must match the delegated task. The full
|
||||
first release remains incomplete. Native input is paused after physical Escape.
|
||||
R6 is not accepted. Main remains at a8f451e. The full first-release goal remains
|
||||
active and incomplete. User explicitly reauthorized native testing after Escape.
|
||||
|
||||
## Current evidence
|
||||
## Frozen production and automated evidence
|
||||
|
||||
- Core0b58107: Linux307 tests pass, one explicitly ignored benchmark; strict
|
||||
all-target core Clippy passes. Windows15 R6+28 workflows pass after final
|
||||
allocation-edge filtering. Source migration/compatibility evidence is retained.
|
||||
- Desktopb403039: Windows257 tests, strict all-target desktop Clippy and release
|
||||
build pass. Binary D:/CodexBuilds/Capella2-data-target/release/arcadia-studio.exe,
|
||||
SHA256 d4aa6ae7d36f429c0cb639cdf57e33cb8b99d158a9629c95a1510d24e406fab5.
|
||||
The later bounded scenario editor is not covered by this binary.
|
||||
- Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
|
||||
Linux22.381/111.662/408.129ms. These are single-run validation measurements.
|
||||
- Server41 prior tests passed. The new qualified Call/Reply workflow and all4 R6
|
||||
HTTP tests now pass with strict all-target server Clippy. Actual HTTP proves
|
||||
distinct reused/inherited/recursive field paths, preserved expression whitespace,
|
||||
phase/path422 with unchanged ETag/model/source/disk, source edits, enclosing-hop
|
||||
and terminal/base reidentification, correlation and exact restart persistence.
|
||||
Initial new-test failures were fixture misuse of untyped Port construction and
|
||||
incorrect assumption that transaction errors carry structured issues. Tests now
|
||||
use make_port and the documented command_rejected/detail plus validation endpoint.
|
||||
- Published data example was imported into actual frozen server32033FFE. Complete
|
||||
Project/StoredProject responses passed served/local OpenAPI validation; obsolete
|
||||
shapes rejected. reST strict rendering and81 OpenAPI schemas passed.
|
||||
- Independent spec review found one stale identity-only port-compatibility paragraph;
|
||||
fixed to directional explicit ancestry and strict HTML rendering passed. No other
|
||||
material R6 spec/API contradiction found in that review.
|
||||
Data integration ab661f2: Windows607 and Linux610 workspace tests pass, one explicit
|
||||
benchmark ignored each; strict workspace all-target Clippy passes on both.
|
||||
Windows261 desktop tests include the focused bounded scenario editor; Linux release
|
||||
desktop/server build passed. Logs under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||
r6-workspace-windows-ab661f2.log and r6-workspace-windows-clippy-ab661f2.log.
|
||||
Linux logs in Capella2-data-linux-review/target/linux-verification:
|
||||
workspace-ab661f2.log, workspace-clippy-ab661f2.log, release-ab661f2.log.
|
||||
|
||||
Logs are under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||
r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log,
|
||||
r6-server-tests-after.log and api-r6-contract. Linux core evidence is under
|
||||
D:/CodexBuilds/Capella2-data-linux-review/target/linux-verification/report.md.
|
||||
Windows frozen executable ui-r6-windows/arcadia-studio.exe SHA256
|
||||
C3ABD9E550E1AD6824DFE273EB27A3ABB35C5C0DA97C819DCB0AB2586AF5450C.
|
||||
Current native window1835476, process session37598. No output observed; runtime.log
|
||||
has not been created by Tee-Object (do not claim an empty log file).
|
||||
|
||||
## Active checks and remaining gates
|
||||
All4 actual R6 HTTP tests pass with strict server Clippy: published data example,
|
||||
protocol/artifact identity, rejected defaults, qualified reused/inherited/recursive
|
||||
Call/Reply paths, phase/path422 with unchanged ETag/model/source/disk, source edits,
|
||||
hop/terminal/base reidentification, correlation and restart persistence. Source3
|
||||
coupled/source bundle migrations report correctly and opening alone does not write.
|
||||
Logs: r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log.
|
||||
Initial fixture errors (untyped Port and transaction-error envelope assumption)
|
||||
were corrected before passing. Published example responses from server32033FFE
|
||||
passed served/local OpenAPI validation; obsolete shapes rejected. Strict reST HTML
|
||||
and81 OpenAPI schemas passed. One stale port compatibility paragraph was corrected.
|
||||
|
||||
The isolated Linux checkout was fast-forwarded to b403039; full workspace tests are
|
||||
running in exec98587 with its dedicated data-linux-target. Initial filesystem wait
|
||||
was verified as a live Cargo process in p9_client_rpc; it subsequently compiled.
|
||||
Do not restart from an observation timeout. Bounded scenario editor production and
|
||||
regressions are being tested by the desktop worker. Independent review found stale
|
||||
values hiding an alternative reply call; its fix is included in that worker pass.
|
||||
Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
|
||||
Linux22.381/111.662/408.129ms. Single-run core validation timings, not UI latency.
|
||||
|
||||
Before acceptance: integrated Windows/Linux checks, native data/association/scenario
|
||||
value workflows at normal/minimum sizes, source3 migration diagnostics, Save/reopen,
|
||||
and closure of material review findings. No R6 visual or real-app acceptance claim.
|
||||
## Personally observed Windows native workflows
|
||||
|
||||
Loaded the isolated Engineering data source fixture. Its architecture view shows
|
||||
nested fields, association role/multiplicity labels, self-reference navigation and
|
||||
selectable endpoint labels. No selection-induced dirty state observed.
|
||||
|
||||
Voltage maximum48→12 with default24 rejects when navigating; entered12 survives,
|
||||
accepted summary remains0..48/default24, and a located default error is shown.
|
||||
Repair default→12 applies together; Undo restores both old values, Redo restores
|
||||
both new values. Created Cooling water with Material/Flow and contained Mass flow
|
||||
rate Real1..1, unit kg/s, explicit default0.0. Save All wrote an included sa/types.arcadia
|
||||
file with matching typed values, leaving13objects/4links/1 expected missing-System
|
||||
warning. Navigation allocation changed Synchronous→Asynchronous; Monitoring's
|
||||
separate Asynchronous/Read allocation is preserved. Removing Monitoring allocation
|
||||
keeps Navigate; Undo restores the allocation and original checks. Followed inherited
|
||||
Latitude from Located asset to Coordinate. Saved all edits. Data layout inspected
|
||||
at1440x900 and1100x700; no overlapping controls observed.
|
||||
|
||||
Separate ui-r6-scenario-windows/model/project.arcadia was constructed with the core
|
||||
API from the saved native data, adding functional ports/exchange, correlated
|
||||
Call/Reply, reused/inherited/recursive records and30 extra fields.53objects/5links
|
||||
validate and source round-trip exactly. fixture-generator.rs retained as evidence;
|
||||
temporary project example removed. Initial generator compile/load errors were
|
||||
fixture-only and fixed using the bundle-aware loader; final build has no warnings.
|
||||
This preparation is not native scenario authoring evidence.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
Native scenario focused/path/phase/draft behavior and paging; actual saved reopen;
|
||||
source3 migration diagnostics; Linux native data/scenario workflows. Closure of
|
||||
material findings before R6 acceptance. Independent semantic/correctness/spec
|
||||
reviews already drove fixes; final native observations may require further review.
|
||||
The broader release still needs semantic reconciliation, direct scenario/process
|
||||
editing, lifecycle/hosted/help work and fresh cross-disciplinary end-state review.
|
||||
|
||||
## Native findings and follow-up fixes (2026-09-06)
|
||||
|
||||
At1100x700, the53-object recursive fixture exposed32/33 nested fields in two pages.
|
||||
Native Call+Reply creation stored distinct Origin/Latitude and Destination/Latitude
|
||||
expressions despite their shared terminal UUID. One Undo removed the pair; Redo
|
||||
restored it. Save All wrote55objects/5links; source confirms both paths and reply
|
||||
correlation. Three expected checks concern the absent SA System and two unallocated
|
||||
functions. The saved evidence is ui-r6-scenario-windows/model/project.arcadia.
|
||||
|
||||
A third-hop popup exposed a material UI defect: repeated long path prefixes hid
|
||||
all terminal field names. Fix74989b6 shows the terminal name first, sorts choices,
|
||||
and retains the full address as wrapping detail text.262 desktop tests, strict
|
||||
Clippy and release build passed; native retest is pending. Intermediate fixed hash
|
||||
E592165A6FFFAEC64B67FF0A33FB4E78571527EE3C8F2CC457666DA6C277D5CB.
|
||||
|
||||
Native source3 import showed8semantic defaults,2generated allocations,1unresolved
|
||||
value address and left the original hash305F19D1B79F99F8D4CF627BA2E9CA6836BB7959CC0070227815937A699EAFEA unchanged.
|
||||
Its old desktop Save All path requested a new file and preserved the original, but
|
||||
canonical regeneration discarded comments/nesting in the upgraded copy. This is a
|
||||
material desktop/source parity defect, not a core bundle migration defect. The fix
|
||||
keeps the historical single-document adapter only for source1/2; source3+ follows
|
||||
bundle migration and propagates its report into Model checks. A regression passes
|
||||
for actual disk single-file and included-file open, no write until explicit save,
|
||||
comments, lexical ownership, report, exact saved source and clean reopened state.
|
||||
Full desktop/Clippy/build and native retest are in progress; do not accept R6 yet.
|
||||
|
||||
The old Windows native window1835476 closed normally; exec37598 exited0 with no
|
||||
runtime output beyond a newline. Re-inventory before opening the rebuilt binary.
|
||||
|
||||
## Migration retest and bounded property browsing
|
||||
|
||||
f5467e5 passed263 desktop tests, strict all-target desktop Clippy and optimized
|
||||
release on Windows/Linux. Frozen Windows SHA256
|
||||
33D1B2B6709BBC734E265568C7F93CB86A506497DEF3F4531669E5ABF8B2F039.
|
||||
Native fresh legacy.arcadia import preserved original bytes until Save All,
|
||||
displayed8defaults/2allocations/1unresolved address, and retained comments and
|
||||
nested fields in its text editor. Save All kept the same file and source4 wording;
|
||||
reopen was clean15objects/3links/4checks without a migration report. Saved SHA256
|
||||
A8C0A689FEA6CC352007ACB127DE4A45E05290830DE815385CFFBAB777255543.
|
||||
Evidence: ui-r6-final-windows/migration/legacy.arcadia.
|
||||
|
||||
The55-object recursive scenario reopened with both qualified expressions intact.
|
||||
Leaf-first labels fixed the hidden terminal names, but existing-value properties
|
||||
still flattened every representative path, causing repeated indistinguishable
|
||||
leaf names. The follow-up browses one record at a time, with24-choice pages and
|
||||
wrapping current address/location text. Up/root/page actions change only browser
|
||||
state; draft field and expression are preserved.264 desktop tests and strict
|
||||
Clippy passed, including wide-record page coverage and draft preservation across
|
||||
recursive browsing. Optimized build and native retest remain pending. Frozen
|
||||
f5467e5 window3476204 closed normally; exec93768 had no runtime output.
|
||||
|
||||
## b35dede native browser acceptance — Windows
|
||||
|
||||
264 desktop tests, strict Clippy and release passed on Windows and Linux. Native
|
||||
Windows verified32-field pages with unique terminal names, clean paging, explicit
|
||||
recursive browse, root navigation retaining draft expressions, and normal/1100x700
|
||||
layout. Saved Origin/Next coordinate/Latitude expression origin.next.latitude + 0.5
|
||||
while preserving Destination/Latitude. Disk source has both qualified paths, and
|
||||
reopened native properties confirm the recursive expression and clean55objects/5links.
|
||||
Frozen Windows SHA256717B8D5AFF501F42473E043BD49EE3E5E017ABBBC84AB8C3AE85AA37121FBD50;
|
||||
evidence ui-r6-browser-windows/model/project.arcadia. Independent read-only focused
|
||||
review found no medium/high issue; noted only page1 reset after choosing a field.
|
||||
|
||||
Linux ELF SHA256EEDF02913CE69C4D5F2B820A48E375A0D22791A2CEC721D9E29F5532370B9E40
|
||||
launched in WSLg but capture timed out awaiting app access. No Linux native acceptance.
|
||||
The user subsequently stopped Computer Use with Escape on the combined Windows
|
||||
scenario build's initial capture. All native input stopped; code integration/gates
|
||||
continue. R6 is not accepted until Linux native gates close.
|
||||
|
||||
## Root Linux native data trial — 2026-09-06
|
||||
|
||||
Frozen60d162c, Wayland1440x900, ui-behavior-linux-60d162c/model/project.arcadia.
|
||||
ELF SHA2568CDA9215FBD0B653CD6FB81F3884BF891BF931C659E2B926863134BDD4C43B5A.
|
||||
Created Retry budget (7fa58d09-f9b4-4ab9-9114-1cadc67bf6e9), Integer0..4.
|
||||
Default9 was rejected with invalid_data_default and retained the draft; correction2
|
||||
saved to the new included sa/types.arcadia. Undo/Redo restored the entire edit.
|
||||
Original Navigation request call8888395e retained Origin/Next/Latitude expression
|
||||
origin.next.latitude +0.5. Destination paging stayed clean and retained its value;
|
||||
browsed Next coordinate, selected Latitude and saved destination.next.latitude +0.25.
|
||||
Exact source contains both independent qualified paths. Native reopen loaded clean65
|
||||
objects after the scenario additions, including Retry budget and recursive values.
|
||||
No Linux association/allocation trial or compact layout acceptance is claimed.
|
||||
Wayland process61870 subsequently closed0 without serious runtime output.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Hosted desktop editing
|
||||
|
||||
Status: implementation, 24 focused tests, all 351 Windows desktop tests, strict desktop all-target Clippy, formatting and optimized build pass. Native and Linux integration acceptance remain with the lead. Base: 1086e54. The lead owns integration, Linux and native acceptance.
|
||||
|
||||
## Contract and ownership
|
||||
|
||||
The optional desktop adapter uses the existing hosted envelope and source-project REST routes. Open compiles and checks the coupled source/model; Publish creates a resource; Save uses the captured opaque ETag. Document remains the sole accepted local history. Complete authored bundles retain comments/includes and Undo stays local until an explicit save. No server, metamodel, persistence or general synchronization framework was added.
|
||||
|
||||
A hosted handle is separate from disk ownership. Successful publication/open attaches it; successful source Save As switches to local ownership. Failed saves and JSON exports preserve the active destination. Requests carry a unique ticket, desktop document identity, local revision and exact compiled snapshot. Stale replies are ignored and late receipts cannot mark newer edits saved or close them.
|
||||
|
||||
Conflicts keep local content and fetch a separate server review. Reload is one undoable accepted change. Readback can acknowledge an uncertain write only when the exact submitted bundle matches; publication is never automatically replayed. Known publication IDs survive lost follow-up reads. Review and reconciliation carry their own connection, including when publishing to a different server. Corrected credentials replace only matching-base handles.
|
||||
|
||||
Credentials are masked, redacted from Debug/errors and kept in memory. URLs reject credentials/query/fragments; remote connections require HTTPS, with HTTP supported on loopback. Redirects/retries are disabled. Request, response, source and time budgets are bounded. The implementation reuses already locked reqwest/Rustls dependencies.
|
||||
|
||||
Owned code: hosted/{mod,client,view}.rs, transport tests, app/hosted.rs and lifecycle tests; narrow App/main/Cargo hooks. The lead explicitly authorized the three views.rs header hooks: hosted status, Hosted models action and active Save label. Other shared UI/property/harness files were untouched.
|
||||
|
||||
## Focused evidence
|
||||
|
||||
Windows, dedicated target D:/CodexBuilds/Capella2-scenario-target:
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||
cargo test --locked --offline -p oa-desktop hosted
|
||||
```
|
||||
|
||||
24 tests pass: 13 transport tests and 11 lifecycle tests. Transport uses actual loopback sockets, production oa-server fixtures and bounded fault fixtures. The actual App test consumes Iced Tasks and routes real HTTP completions through App::update: Publish, graphical edit/Save, competing API edit/412, corrected credentials, direct review Reload, Undo/Save, uncertain-write Check, catalog/Open and Save & continue to New. These are runtime/model tests, not native GUI acceptance.
|
||||
|
||||
Fresh independent lifecycle review found stale captured credentials after reconnect. The production fix refreshes matching-base session, uncertain-write and review connections. The actual App regression fails when that refresh is removed and passes when restored; hosted-credentials-red.log records the expected failure. A restored-source timestamp initially reused Cargo's red binary; advancing the source modification time forced the verified rebuild. hosted-focused-test.log records the passing corrected suite. Reviewer re-review found no remaining production defect; its test-coverage suggestion was incorporated with separate direct Reload and uncertain Check paths.
|
||||
|
||||
A first build caught adapter visibility and test cursor API issues, both fixed. A network test originally required connection_failed for a closed Windows port; Windows can instead hit the bounded timeout. The assertion now accepts either certain read failure while checking that the result is not an uncertain mutation.
|
||||
|
||||
## Remaining acceptance
|
||||
|
||||
The final Windows desktop test run passed 351 tests; strict all-target Clippy and formatting passed. Clippy caught an unnecessary test-only PathBuf allocation, which was removed. Hosted inputs now show their disabled state while requests run. The optimized desktop build passed in 2m 46s. Freeze a separate native trial executable with commit/hash. The lead must exercise default/minimum Windows and Linux dialogs, publish/open, graphical and textual save, API artifact changes, conflict/reload/Undo, offline recovery and local Save As. Update guide/server/scope claims that hosted desktop editing is unavailable after integration; this worker does not own those shared documents. Hosted editing remains explicit save/reload, without automatic merges, server history or collaborative synchronization.
|
||||
|
||||
|
||||
## Verified Windows artifact and commands
|
||||
|
||||
Production commit: `6fcc238c5240d35be5e2ef828b16fbf1bd01bdbd` (feature `53a509553daa297a8f602262d0498142010829d4`). The isolated worktree is `D:/CodexBuilds/Capella2-hosted-desktop` on `codex/hosted-desktop`.
|
||||
|
||||
From that checkout, the completed gates were:
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
|
||||
cargo test --locked --offline -p oa-desktop
|
||||
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline -p oa-desktop --release
|
||||
```
|
||||
|
||||
The target contains `hosted-desktop-test.log` (351 passed, zero failures/ignored), `hosted-desktop-clippy.log`, `hosted-fmt.log` and `hosted-desktop-build.log`. The transport tests exercised local sockets only; no external deployment or native input occurred.
|
||||
|
||||
Frozen executable: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hosted-windows-6fcc238/arcadia-studio.exe`.
|
||||
|
||||
SHA-256: `51E4C02F1265D3E9B140C2BCF34A8B11E25E5B0D1D32C83E987FEA4BEB4FC061`.
|
||||
|
||||
The release output and frozen copy hashes match. A sibling `manifest.json` records the production commit, gates and pending acceptance. No existing trial artifact was replaced. The assigned scenario target and HOST index are idle and returned to the lead after this evidence checkpoint.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Direct process / functional-chain editing
|
||||
|
||||
Status: implementation and automated Windows verification passed on 2026-09-06 in `codex/process-direct`, based on
|
||||
1bf8ebc in `D:/CodexBuilds/Capella2-scenario-direct`; dedicated target remains
|
||||
`D:/CodexBuilds/Capella2-scenario-target`. The prior scenario branch is retained.
|
||||
No native acceptance is claimed; root owns Windows/Linux native operation.
|
||||
|
||||
## Decisions and observable outcome
|
||||
|
||||
An engineer can involve existing work/exchanges more than once, draw guarded
|
||||
sequence links and controls, insert on a selected path, reconnect its endpoint,
|
||||
inspect/reuse a subprocess, and undo/save/reopen with contextual identities intact.
|
||||
Extend the existing derived topology graph and ordinary typed drafts. No core
|
||||
schema, persisted geometry, source grammar or REST route is added.
|
||||
|
||||
Palette tools capture document/owner/revision/epoch and a specific contribution,
|
||||
link or presentation slot. Canvas click-click and drag connect local occurrence
|
||||
IDs. A link insertion preserves the captured link UUID, alias, description and
|
||||
guard on the upstream segment, creates the downstream segment and new occurrence,
|
||||
and updates `steps` in one validated Batch. Membership/presentation order remains
|
||||
separate from SequenceLink topology. Incomplete control guards/cardinalities are
|
||||
warnings, as in the core. Reconnection retains the link's authored properties.
|
||||
|
||||
Select always means the local occurrence/relation. Shared source navigation uses
|
||||
explicit Open; reusable previews expand without copying or reparenting source
|
||||
contributions. Inner preview endpoints are not caller-owned endpoints. Capella's
|
||||
richer cross-reference hierarchy linking is an unresolved semantic extension,
|
||||
not implied by this milestone. Existing explicit legacy-path conversion remains.
|
||||
|
||||
Keep canvas and bounded draft independently scrollable, use searchable paged
|
||||
contextual choices, and default to readable zoom with explicit Fit and scrolling.
|
||||
Transient drawing cancels on context, tool, bounds, scroll, resize or focus changes;
|
||||
form inputs survive failed validation/source updates. Reuse passive scroll
|
||||
observations including async-save delivery. Commit through Document::execute,
|
||||
acknowledging and selecting the created occurrence only after model/source success.
|
||||
|
||||
## Ownership and verification
|
||||
|
||||
Own `behavior/topology.rs` and local tests, with narrow `behavior.rs` and `app.rs`
|
||||
routing/lifecycle changes. Do not change DATA/Linux/semantic checkouts or perform
|
||||
native input. Verify production pointer events, planners, App and model/source:
|
||||
|
||||
1. OA/SA repeated work/exchange involvement preserves shared records.
|
||||
2. Alternative/parallel/loop creation, guards and incomplete warnings.
|
||||
3. Specific guarded-link insertion preserves metadata; incompatible adjacency rejects.
|
||||
4. Reconnect/delete/Undo/Redo retain unrelated paths and exact source.
|
||||
5. Repeated subprocess reuse, explicit navigation, expansion and recursive rejection.
|
||||
6. Legacy conversion and presentation reorder independent of explicit topology.
|
||||
7. Dirty/source-failure/stale/scroll-save/resize/Escape/focus lifetimes retain drafts.
|
||||
8. Nested source save/reopen and ordinary serialized command/model parity.
|
||||
|
||||
Run focused invariants, all desktop tests, strict all-target desktop Clippy and
|
||||
optimized desktop build with --locked --offline and the dedicated target. Record
|
||||
actual evidence and remaining native tasks before freezing. Root independently
|
||||
reviews and native-tests the freeze; full-release completeness remains separate.
|
||||
|
||||
## Original implementation freeze and actual evidence — 2026-09-06
|
||||
|
||||
Production is `7a1e12bc7c18c5693de688f054293bacd095f4d8` (`7a1e12b`).
|
||||
The prior `codex/scenario-direct` branch remains at its original head. Production
|
||||
changes are confined to desktop topology and its local direct/view/test modules,
|
||||
App process routing, the existing passive-scroll observer and one updated passive
|
||||
lifecycle regression. `scenario.rs` and scenario message-label construction are
|
||||
unchanged, allowing the root's separate DATA native fixes to merge independently.
|
||||
|
||||
The palette now draws work/exchange/reuse/control contributions and sequence links
|
||||
with click-click or drag. Selected link endpoint handles reconnect by identity;
|
||||
selected contribution handles reorder peers within one derived path column.
|
||||
Source navigation is explicit. Expanded reference previews expose all directly
|
||||
owned source contributions without copying them or making inner endpoints local.
|
||||
Every picker has search and 24-item pages. Active tools have distinct styling.
|
||||
Create/Cancel are outside the form's internal scroller. The canvas stays visible
|
||||
while forms are pending. New occurrence selection is restored after the final
|
||||
SetSteps command, and successful edits reveal it in the graph.
|
||||
|
||||
The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit
|
||||
may produce a small overview. Palette changes preserve the chosen Fit/manual
|
||||
camera; use the explicit 100% control to enlarge it. This supersedes the original
|
||||
forced-100% policy after native testing showed that it hid intended endpoints.
|
||||
No positions are persisted. A source-only exchange sequence retains its
|
||||
legacy linear meaning: two or more entries have explicit Convert. The original
|
||||
one-exchange fallback offered only Work/Control/Reuse; independent review found
|
||||
that this blocked exchange-only continuation. The correction is recorded below.
|
||||
Disconnected exchange-only topology cannot be represented by the accepted
|
||||
contract; the editor does not introduce an invisible mode flag.
|
||||
|
||||
The new App resize regression initially failed because existing window handling
|
||||
only invalidated scenario contexts; it now invalidates process contexts too.
|
||||
Passive viewport and both scroll reports are accepted before busy/modal gates,
|
||||
validated by document/owner/revision, and cannot apply properties or discard a
|
||||
behavior draft. The shared observer distinguishes its scenario/process delivery.
|
||||
A separate captured-link probe failed because two existing parallel guards had
|
||||
the same hit target. Distinct derived routes now make each relation selectable;
|
||||
source/target/UUID/guard remain unchanged. The failing probe is saved alongside
|
||||
the passing final suite as `process-parallel-selection-before.log`.
|
||||
|
||||
Actual commands used `--locked --offline` and the dedicated scenario target:
|
||||
|
||||
- Full desktop tests: **303 passed**, zero failed/ignored. Seventeen new process
|
||||
tests exercise production pointer sequences, exact guarded-link insertion,
|
||||
duplicate-link selection, same-owner reorder, source failure/drafts, busy save
|
||||
scrolling, dirty source/properties, real resize, reusable-source Open/Back,
|
||||
bounded widget trees and nested multi-file source/serialized-command parity.
|
||||
- Strict all-target desktop Clippy: **passed** (`-D warnings`).
|
||||
- Independent existing core suites `release_topology`, `process_diagnostics` and
|
||||
`source_nested_updates`: **19 passed**. These cover complete alternative,
|
||||
parallel and loop topology; two-direction transition closure; deletion and
|
||||
reidentification; legacy sequences and exact nested source ownership/history.
|
||||
- Optimized desktop build: **passed**. Owned sources were formatted with Rustfmt
|
||||
edition 2024; `git diff --check` passed before the production commit.
|
||||
|
||||
The source/JSON commands and core invariants are unchanged. These tests do not
|
||||
claim a new actual HTTP transport trial or native acceptance.
|
||||
|
||||
Frozen Windows directory:
|
||||
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`.
|
||||
It contains the binary, manifest and five verification/probe logs. Binary size:
|
||||
20409856 bytes. SHA-256:
|
||||
`701ECE6CD329A1CBE50D4DAEBFCE33AD7A93E6906295E74C3290653706F8455A`.
|
||||
The frozen copy was hash-compared with the release target binary.
|
||||
|
||||
## Independent-review correction — exchange-only continuation
|
||||
|
||||
Production follow-up is `8bc564b4b2c3b6d12eb61fc09aec7f129f6e45b7` (`8bc564b`).
|
||||
Independent review found a real dead end: after the first Exchange was created in
|
||||
an empty process/chain, the accepted model classified it as an ordered exchange
|
||||
path. There was no path to convert and the fallback omitted another Exchange.
|
||||
|
||||
Ordered paths now offer **Append exchange…**, including a single contribution.
|
||||
The form names the captured final contribution and explains that Create adds an
|
||||
explicit sequence link. Its contextual picker accepts exchanges whose source
|
||||
matches the preceding exchange's target. Create emits one existing validated
|
||||
Batch: new exchange occurrence, preceding-to-new SequenceLink and SetSteps;
|
||||
existing implied links in longer ordered paths are materialized in that same
|
||||
transaction. The second contribution therefore restores the direct topology
|
||||
palette without inventing disconnected topology, global exchanges or a mode flag.
|
||||
Source reuse, same-owner endpoints and the cross-reference semantic boundary remain.
|
||||
|
||||
Two new regressions both exercise OA and SA. The App regression starts with an
|
||||
empty owner, creates its first exchange, invokes the actual Append event and
|
||||
creates its second exchange. It verifies exactly one link, two distinct occurrence
|
||||
IDs, unchanged shared records, one history entry per Create, exact model/source
|
||||
Undo/Redo and source reopen. The independent proposal regression preserves a
|
||||
multi-entry ordered path, rejects an incompatible exchange without losing its
|
||||
form, and validates the serialized ordinary Batch through core execution.
|
||||
|
||||
Focused regressions: **2 passed**. Full desktop tests: **305 passed**, zero
|
||||
failed/ignored. Strict all-target desktop Clippy: **passed** (`-D warnings`).
|
||||
Commands used `--locked --offline` and the same dedicated target. The final full
|
||||
suite took 10.75 seconds; Clippy took 4.96 seconds. Production changes are confined
|
||||
to `topology/direct.rs`, `topology/view.rs`, local tests and `app/process_canvas.rs`.
|
||||
No native input or new actual HTTP transport trial was performed.
|
||||
|
||||
Updated optimized build: **passed** in 38.80 seconds. Current frozen Windows
|
||||
artifact, manifest and three verification logs are in
|
||||
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`.
|
||||
Binary size: 20430336 bytes; SHA-256:
|
||||
`8BD5C84DF0F4E333C1955E6E9299F431081912E1A504ED13355E0BFA70F8B8BD`.
|
||||
The frozen copy was hash-compared with the release target binary. The prior
|
||||
`freeze-process-7a1e12b` remains available as historical evidence.
|
||||
|
||||
## Root-owned native acceptance still required
|
||||
|
||||
At 1440x900 and 1100x700 on Windows and Linux, operate the actual application:
|
||||
|
||||
1. Start an OA process and SA chain, involve matching existing work/exchanges,
|
||||
repeat a shared source with a distinct contextual name, and draw links with
|
||||
both click-click and drag. Inspect source identity and contextual properties.
|
||||
2. Draw Choice/Merge alternatives and Fork/Join parallel paths; enter `ready` and
|
||||
`else`, create a loop repeat/exit, and inspect incomplete warnings. Insert an
|
||||
exchange/control on an existing guarded path, reconnect an endpoint and reject
|
||||
an incompatible target; confirm the original path metadata and other branch.
|
||||
3. Reuse a subprocess twice, select its body without navigation, expand one local
|
||||
reference and inspect the count, Open the shared source, then Back to its caller.
|
||||
Reorder peer contributions and verify links are unchanged; cross-column movement
|
||||
is rejected with the existing topology explanation.
|
||||
4. Test Fit/100%, inner and outer wheel/scrollbar changes, resize, Escape and
|
||||
unfocus during an armed or held gesture. Verify no stale endpoint/slot commits.
|
||||
Confirm Create/Cancel remains accessible while a long form is open, search/page
|
||||
changes retain its values, navigation is guarded and save-time scrolling recovers.
|
||||
5. Start an empty owner, add its first exchange, use Append exchange for its second,
|
||||
and verify the visible link/direct palette. Also append/convert a legacy multi-exchange path.
|
||||
Undo/Redo the conversion and direct edits, save/reopen a nested source project,
|
||||
then inspect the same occurrence/link IDs, guards and shared source records.
|
||||
|
||||
No native/UI input was performed in this task. Cross-reference inner-chain linking
|
||||
remains an explicit unresolved semantic extension; this milestone does not claim
|
||||
full Capella functional-chain equivalence or complete the first release.
|
||||
|
||||
## Root Linux native direct-chain trial — 2026-09-06
|
||||
|
||||
Frozen60d162c Wayland1440x900. Created empty SA Functional chain; Work palette and
|
||||
canvas clicks involved existing Request navigation and Provide navigation. Drew
|
||||
Sequence link by dragging their cards, entered ready, created it. Exchange palette
|
||||
and click on that exact guarded path chose existing Navigation request. Create
|
||||
inserted its occurrence between functions:69objects/7links/3expected model checks.
|
||||
Fit69% reveals all three cards and guard. Undo68/6 and Redo69/7 passed; Save All
|
||||
created included sa/behavior/process_new_functional_chain.arcadia.
|
||||
|
||||
Chain768ad41c-3bf7-4a89-8163-41963157f1b1; function occurrences a77f6697 and aa6526a7;
|
||||
exchange occurrence9562fa0f-c9ff-42d1-aff5-da17ec3c827f. Original link
|
||||
ead846bd-3245-44b7-b6aa-1223865f533b retains ready and now targets inserted exchange;
|
||||
new link6f32045b-a598-47ae-bfe7-3f9b7f52cdf3 connects exchange to Provide navigation.
|
||||
Shared function/exchange records are unchanged. Saved file was inspected; native
|
||||
chain reopen is still pending. Wayland61870 closed0 normally without serious output.
|
||||
OA, branch/fork/loop, reuse, reconnect/rejection, contextual names, Exchange-first
|
||||
continuation, compact/Windows trials and gesture cancellation remain required.
|
||||
|
||||
|
||||
## Preserve camera and viewport during process authoring
|
||||
|
||||
Root native testing exposed two independent UI defects in the combined build.
|
||||
After Fit showed a three-card chain at69%, choosing a control reset scale to100%
|
||||
and hid remote cards. The implementation intentionally enlarged drawing tools for
|
||||
legibility, but the real scaled input path already supports editing at Fit. Tool
|
||||
selection now changes only the tool; explicit camera actions and document/owner
|
||||
navigation retain their existing behavior and gesture epoch invalidation.
|
||||
|
||||
Opening Two paths after revealing a remote OR control also reset the canvas to
|
||||
the beginning without changing source/model. Its two palette rows were conditional
|
||||
outer Column children. Removing them moved the canvas subtree to a different
|
||||
sibling slot; Iced discarded its local scroll state. A permanent controls Column
|
||||
now contains the palette and pending notice, preserving the canvas position in the
|
||||
widget tree through form open/cancel. This follows the accepted scenario approach.
|
||||
|
||||
The compact Two paths form now has persistent First/Second path work and guard
|
||||
labels, plus Preceding/Following contribution labels. Typed values no longer erase
|
||||
those meanings. Existing pinned Create/Cancel controls remain in place.
|
||||
|
||||
Three regression tests exercise OA and SA: Fit and manual-camera preservation
|
||||
across all palette tools; production Control insertion and click/drag sequence
|
||||
links at15%, approximately69% and150% with nonzero canvas/scroll origins; and
|
||||
actual Iced widget-tree diff through Control, Two paths and Connect form open/cancel
|
||||
at1440x900 and1100x700. The latter marks the scrollable's opaque local state during
|
||||
diff only, proving that the state owning offsets survives, while checking the
|
||||
unchanged document snapshot. Both defect tests failed against original production
|
||||
code (0.6898551→1.0 and dropped local state), then passed after correction.
|
||||
|
||||
The focused process suite passed15 tests. Production correction is `cf33785`.
|
||||
Full desktop verification passed **325 tests, zero failures/ignored** on both
|
||||
Windows and Linux. Strict all-target desktop Clippy, whole-workspace formatting
|
||||
and optimized desktop builds passed on both. Windows build48.01s, Linux37.89s.
|
||||
No core, source format or REST behavior changed.
|
||||
|
||||
Commands from each platform's assigned DATA checkout, using its dedicated target:
|
||||
|
||||
```sh
|
||||
cargo test --locked --offline -p oa-desktop
|
||||
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline -p oa-desktop --release
|
||||
```
|
||||
|
||||
Windows CARGO_TARGET_DIR is `D:/CodexBuilds/Capella2-data-target`. Linux uses
|
||||
`/mnt/d/CodexBuilds/Capella2-data-linux-target` and adds
|
||||
`--config target/linux-cargo.toml` to test, Clippy and build, with Rust1.98.1 from
|
||||
`/home/sorokinoleg/.cache/arcadia/rust/bin`. Logs in both targets are
|
||||
`process-camera-desktop-test.log`, `process-camera-desktop-clippy.log`,
|
||||
`process-camera-fmt.log` and `process-camera-desktop-build.log`. Windows also has
|
||||
both expected red regression logs and `process-camera-focused.log`.
|
||||
|
||||
Frozen native trial executables under
|
||||
`D:/CodexBuilds/Capella2-release1-01a06e2e`:
|
||||
|
||||
- `ui-process-camera-windows-cf33785/arcadia-studio.exe` SHA256
|
||||
`44D306520AF1FF9BC26AAD96163EEAB41CDCB68ACD6914F802E2B90F02DF7E74`
|
||||
- `ui-process-camera-linux-cf33785/arcadia-studio` SHA256
|
||||
`D1B26795579CA02F74BB221DC3E94B2E97946378AA1B4ED6BD6795FD44FBE52E`
|
||||
|
||||
Each has a verification manifest; copy hashes match the build output, and Linux
|
||||
executable access was checked without launching. Both build targets are idle and
|
||||
returned to the lead. This task used no native input. Root must verify preserved
|
||||
Fit scale and the remotely scrolled region through tool/form changes and inspect
|
||||
the persistent compact-form labels on the actual application.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Behavior scroll identity correction
|
||||
|
||||
Status: implementation, focused checks and full Windows/Linux desktop gates passed.
|
||||
Independent re-review and native acceptance belong to the lead. Base `1b0b072` includes the independently reviewed process
|
||||
camera/form corrections in `cf33785`.
|
||||
|
||||
The independent actual-Iced probe reproduced a preexisting navigation defect:
|
||||
`topology::State::enter` cleared its mirrored offset, but the retained Scrollable
|
||||
still held y180 and immediately reported it under the next process owner. The
|
||||
reviewer's test is now a repository regression, using Iced's null renderer, real
|
||||
layout, wheel input and observer messages. It failed against the original code
|
||||
with y180 instead of zero and passes with this correction.
|
||||
|
||||
The existing observer now stores its mounted `(document, owner)` identity at
|
||||
construction. Its diff recreates local child widget state only when that pair
|
||||
changes. Keeping the same owner preserves the actual scrollable and its report;
|
||||
revision, epoch, tool and form transitions are deliberately not identity keys.
|
||||
No general state framework, model command, schema or persistence change is added.
|
||||
The shared observer applies the same lifetime to inner-canvas and outer-page
|
||||
scrolling in process and scenario views.
|
||||
|
||||
A second actual-Iced regression seeds a programmatic scroll operation before any
|
||||
observer report, then varies document, owner, revision/epoch and form-like content
|
||||
height. This guards against deriving identity from the last notification. Its
|
||||
matrix covers both message families and both scroll areas: only document/owner
|
||||
navigation resets to zero, and unchanged-owner edits retain y180. It also verifies
|
||||
current event context and one-time reporting. Both defect probes failed before the
|
||||
fix; all three observer tests and 22 process tests now pass. Existing passive clamp,
|
||||
remount, palette camera, form widget-state and stale-gesture tests remain passing.
|
||||
|
||||
The process/native ledger, STATE and other lead-owned documents are untouched.
|
||||
The lead owns independent re-review and actual native navigation acceptance.
|
||||
|
||||
## Platform verification and frozen trial
|
||||
|
||||
Production commit: `8c600087dc406149109f19b90250cc3e3b3ac819`.
|
||||
On both Windows and Ubuntu 24.04, the complete desktop suite passed **327 tests,
|
||||
zero failed or ignored**. Strict all-target desktop Clippy, workspace formatting
|
||||
check and optimized desktop build passed. No native application was launched by
|
||||
this worker; the lead owns runtime acceptance.
|
||||
|
||||
Windows commands, from `D:/CodexBuilds/Capella2-data-integration`:
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-data-target'
|
||||
cargo test --locked --offline -p oa-desktop
|
||||
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline -p oa-desktop --release
|
||||
```
|
||||
|
||||
Linux commands, under WSL Ubuntu-24.04 from
|
||||
`/mnt/d/CodexBuilds/Capella2-data-linux-review`:
|
||||
|
||||
```sh
|
||||
export PATH=/home/sorokinoleg/.cache/arcadia/rust/bin:$PATH
|
||||
export CARGO_TARGET_DIR=/mnt/d/CodexBuilds/Capella2-data-linux-target
|
||||
cargo test --locked --offline --config target/linux-cargo.toml -p oa-desktop
|
||||
cargo clippy --locked --offline --config target/linux-cargo.toml -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline --config target/linux-cargo.toml -p oa-desktop --release
|
||||
```
|
||||
|
||||
Each target contains the corresponding `process-owner-desktop-test.log`,
|
||||
`process-owner-desktop-clippy.log`, `process-owner-fmt.log` and
|
||||
`process-owner-desktop-build.log`. Windows also contains the expected red logs
|
||||
`process-owner-probe-red.log` and `process-mounted-identity-red.log`, plus passing
|
||||
`process-owner-observer-green.log` and `process-owner-focused-green.log`.
|
||||
|
||||
Separate frozen artifacts and manifests are under
|
||||
`D:/CodexBuilds/Capella2-release1-01a06e2e`:
|
||||
|
||||
| Platform | Relative executable | SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| Windows | `ui-process-owner-windows-8c60008/arcadia-studio.exe` | `F64C4D3058E53C8F80E7B8F296F9C4AF89A3FC208E8B2ACD9F7A43181EF59293` |
|
||||
| Linux | `ui-process-owner-linux-8c60008/arcadia-studio` | `00D2E3824C5489FC1DB158ACE81CC10B33B95F6E2EBB8659BABC04B19D5B970D` |
|
||||
|
||||
Source and frozen executable hashes were compared and matched. The Linux frozen
|
||||
binary passed `/usr/bin/test -x`. This is a bounded desktop correction; it does
|
||||
not replace the previously recorded complete workspace and API contract gates.
|
||||
@@ -0,0 +1,311 @@
|
||||
# Direct scenario editing — bounded implementation plan
|
||||
|
||||
Status: implementation and automated Windows checks passed in the isolated
|
||||
`codex/scenario-direct` checkout, based on ab661f2. Initial production is d1d8f0b;
|
||||
independent-review fixes are in 7a66607 and e7e37d4. The focused independent
|
||||
recheck reports no remaining medium/high finding. Root-owned Windows/Linux native
|
||||
trials remain pending. DATA b35dede is integrated in dfd2e41 with 286 desktop
|
||||
tests, strict Clippy and a fresh frozen Windows release artifact. This does not
|
||||
complete the release or process/chain work.
|
||||
|
||||
## Decisions
|
||||
|
||||
Extend the existing `behavior::Scene`; retain its derived lifeline columns and
|
||||
ordered occurrence rows. No model/schema/layout records are added. Lifeline and
|
||||
occurrence UUIDs remain distinct from represented work and global exchanges.
|
||||
|
||||
Select mode selects headers, stems, messages and occurrence boxes. A selected
|
||||
occurrence has a reorder handle; dragging it chooses a visible slot in its same
|
||||
parent sequence. It never reparents or changes lifeline columns. A Message tool
|
||||
uses the existing MessageStyle, with Call + correlated reply initially selected.
|
||||
Click-click or drag between represented lifelines captures a highlighted explicit
|
||||
insertion slot `(parent, before occurrence or append)` and opens the existing
|
||||
typed draft with those endpoints. Exchanges are filtered by layer, viewpoint and
|
||||
the core's represented-endpoint compatibility. Earlier calls retain complete
|
||||
exact-slot validation, cached by document revision, owner, slot and endpoints.
|
||||
Choices use search plus 24-item pages; every final creation still validates the
|
||||
complete ordinary command at the captured insertion slot.
|
||||
No compatible exchange produces an actionable retained draft, not a new global
|
||||
exchange or a silently changed endpoint. Timer remains available as a local tool.
|
||||
|
||||
The canvas remains rendered while a bounded, independently scrollable form is
|
||||
open. Values keep the existing qualified-path editor and lifecycle. Final creation
|
||||
uses ordinary CreateElement plus SetSteps in one Batch; paired call/reply are
|
||||
inserted together and undo together. Reordering uses SetSteps within the captured
|
||||
parent. Existing core validation decides reply order, branch dominance, lifetime,
|
||||
phase and endpoint compatibility. Failed preparation/source writes retain drafts.
|
||||
|
||||
Transient events capture document/scenario/revision/epoch, and the App validates
|
||||
the current Behavior owner before handling them. Selection does not invalidate a
|
||||
gesture within the same scenario. Escape, unfocus, context/tool change and pending
|
||||
drafts cancel gestures. Scroll, bounds changes and actual window resize also
|
||||
cancel the captured source/slot. Ordered raw pointer events are translated by the
|
||||
actual combined inner/page Scrollable offsets. No geometry is persisted.
|
||||
|
||||
Execution bars derive their ends from actual correlated reply positions; separate
|
||||
conditional reply branches render their corresponding spans. Calls without a
|
||||
visible reply use a short explicitly open activation, not a fabricated reply.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
Production-event tests cover hit priority, click-click/drag, scroll-origin changes,
|
||||
Escape/unfocus/stale contexts and exactly one result per gesture. Planner/App tests
|
||||
cover middle/operand insertion, paired identities, complete SetSteps ordering,
|
||||
same-parent reorder, rejected reply-before-call, unchanged global exchanges, exact
|
||||
source/model Undo, draft retention and bounded exchange/call controls. Run focused
|
||||
and full desktop tests, strict all-target desktop Clippy and optimized build with
|
||||
the dedicated `D:/CodexBuilds/Capella2-scenario-target` target.
|
||||
|
||||
Root native tasks (not yet performed):
|
||||
1. Draw an existing exchange again into the middle of a scenario as Call+Reply;
|
||||
enter a qualified value, select both occurrences, Undo/Redo, save/reopen.
|
||||
2. Insert inside an ALT/LOOP operand, reorder within it, reject a reply before its
|
||||
call, and inspect correlated execution bars and preserved occurrence identities.
|
||||
3. Cancel drawing after scrolling/layout change, retry, retain typed values while
|
||||
navigation is blocked, and open/return from a referenced scenario. Verify at
|
||||
1440x900 and 1100x700 on Windows and Linux before scoped acceptance.
|
||||
|
||||
## Initial automated evidence — 2026-09-06 (superseded by review fixes)
|
||||
|
||||
Source checkout: `D:/CodexBuilds/Capella2-scenario-direct`, production commit
|
||||
`d1d8f0bbed5aa0112189138a1cc025affe26c09b`. Changes are confined to desktop
|
||||
`app.rs`, `behavior.rs` and `behavior/scenario.rs`; no core model, persisted layout
|
||||
or schema changes are included.
|
||||
|
||||
All commands used the dedicated `D:/CodexBuilds/Capella2-scenario-target` target
|
||||
with `--locked --offline`:
|
||||
|
||||
- `cargo check -p oa-desktop --all-targets`: passed.
|
||||
- `cargo test -p oa-desktop direct_`: 19 passed, including 13 new direct-scenario
|
||||
tests and six existing matching tests.
|
||||
- `cargo test -p oa-desktop`: final source passed all 274 tests, zero failures or
|
||||
ignored tests. This includes exact source/model Undo, creation and reorder
|
||||
selection, rejected stale contexts, retained failed-write drafts, bounded
|
||||
contextual controls, scroll-origin cancellation and correlated activation spans.
|
||||
- `cargo clippy -p oa-desktop --all-targets -- -D warnings`: passed.
|
||||
- `cargo build -p oa-desktop --release`: passed.
|
||||
- `git diff --check`: passed before the production commit.
|
||||
|
||||
Logs are under `D:/CodexBuilds/`: `Capella2-scenario-check.log`,
|
||||
`Capella2-scenario-direct-tests.log`, `Capella2-scenario-desktop-final-tests.log`,
|
||||
`Capella2-scenario-desktop-clippy.log` and `Capella2-scenario-desktop-release.log`.
|
||||
|
||||
Windows artifact:
|
||||
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
|
||||
20,345,856 bytes, SHA-256
|
||||
`7CC6DCDB7ECC0C6B144137D31DB0BE9BC1555FEFAE6B465DAEABCF52DFE1A166`.
|
||||
This artifact has not been native-tested. Widget-tree/event tests do not establish
|
||||
visual usability, scrolling behavior or GPU rendering acceptance; the three native
|
||||
tasks above remain required. Process/chain direct editing remains outside this
|
||||
bounded implementation.
|
||||
|
||||
|
||||
## Independent-review fixes and final automated evidence — 2026-09-06
|
||||
|
||||
Production commit: `7a666076bfcfd1f2258d2f5508328db5adf40825`, same isolated
|
||||
checkout and dedicated target. Only the three previously owned desktop source
|
||||
files changed. No core/source/schema or process/chain features were added.
|
||||
|
||||
The new event regressions failed on the prior implementation: a bounds shift kept
|
||||
the first click's source/slot armed, and a held drag could still publish its old
|
||||
slot after the bounds changed. Both now cancel. After scrolling, resizing or
|
||||
clipping changes, the next click starts a fresh gesture. App tests also verify
|
||||
unchanged-size notifications retain context, actual resize invalidates it, and
|
||||
scrolling preserves property/source history and the pending behavior form.
|
||||
|
||||
Local Iced inspection established that Scrollable translates its supplied cursor
|
||||
but leaves raw CursorMoved coordinates and the canvas layout unchanged. The
|
||||
existing inner and outer scrollables now have a local, passive observer which
|
||||
reads their actual translation through Widget::operate. Ordinary on_scroll cannot
|
||||
report initial/remounted state or passive content-size clamping. The observer
|
||||
forwards layout, drawing, overlays and event capture, and publishes only changed
|
||||
translations or document/owner context. Reporting before child input makes a
|
||||
result using an old slot stale before App processes it; reporting after input
|
||||
captures wheel/scrollbar changes. The real Iced Scrollable test uses its debug
|
||||
null renderer and verifies 0 → 180 wheel offset → 50 passive clamp → 180 on
|
||||
expansion → 0 after remount, with no repeated messages on unchanged redraws.
|
||||
|
||||
The reported ALT fixture also failed before the fix: a root Call, Reply then
|
||||
Action in operand one, another Reply in operand two, and a third operand without
|
||||
a Reply. Derived execution geometry now splits at operand boundaries, stops each
|
||||
returned operand at its own correlated Reply, leaves its following Action outside
|
||||
that segment, and marks the reply-free operand open. It does not imply chronology
|
||||
between alternative operands or add persistent execution records.
|
||||
|
||||
Picker measurement on the same Windows debug target:
|
||||
|
||||
| Probe | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 500 compatible exchange choices | 7.87 s | 1.46 ms |
|
||||
| Same choices after one name edit | 8.14 s | 1.48 ms |
|
||||
| 100 exact-slot earlier calls, initial validation | not measured | 595 ms |
|
||||
| Those calls after name/value/search/page edits | not measured | 34–39 µs |
|
||||
|
||||
The exchange list no longer clones and validates the entire project per candidate.
|
||||
Final Apply still rejects a Delete inserted before a later Call on that lifeline;
|
||||
the new regression confirms appending that same Delete succeeds. Reply choices
|
||||
retain exact validation, invalidate for changed slot/document/revision/endpoints,
|
||||
and do not lose their cached eligibility while expressions are being typed.
|
||||
These are bounded picker measurements, not complete native-frame timings.
|
||||
|
||||
Final checks (`--locked --offline`): 27 focused matching tests passed; all 282
|
||||
desktop tests passed, zero failures/ignored tests; strict desktop all-target
|
||||
Clippy passed; optimized desktop build passed; git diff --check passed. Clippy
|
||||
requested one test-only nested-if cleanup, which was applied before the final
|
||||
clean Clippy/build.
|
||||
|
||||
Logs under `D:/CodexBuilds/`:
|
||||
`Capella2-scenario-review-before-tests.log`, `Capella2-scenario-alt-before.log`,
|
||||
`Capella2-scenario-scroll-observer-tests.log`,
|
||||
`Capella2-scenario-review-desktop-tests.log`,
|
||||
`Capella2-scenario-review-clippy.log`, `Capella2-scenario-review-release.log`.
|
||||
|
||||
Current Windows binary:
|
||||
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
|
||||
20,349,952 bytes, SHA-256
|
||||
`1AE729097F267DE596AE0970D3A62AE06C0C89CE2FDE70DF980AD785A0E0A8E6`.
|
||||
It supersedes the initial artifact above. Native acceptance is still pending.
|
||||
Root should repeat the three primary native tasks, including inner/outer wheel
|
||||
and scrollbar cancellation at a middle slot, resize/remount/clamp recovery, and
|
||||
the three-operand ALT fixture. The broader release objective remains incomplete.
|
||||
|
||||
|
||||
## Final passive-scroll save correction — 2026-09-06
|
||||
|
||||
Production `e7e37d485d842731d270781bd4e60a323fe4cdba` addresses the focused review's remaining App lifecycle
|
||||
finding. During an async save, Iced can change its scroll position while App's busy
|
||||
gate rejects ordinary messages. The observer had already recorded its report,
|
||||
so dropping that notice left App's offset stale after save completion. The new
|
||||
regression failed before the fix: App retained offset (0, 0) instead of (25, 140).
|
||||
|
||||
App now dispatches only the passive Scrolled event before interaction gates. Its
|
||||
existing handler still validates document, scenario and revision. Mutation input
|
||||
remains gated. The test checks current offsets and cancellation epoch while busy,
|
||||
rejects an old-document observation, delivers a successful Saved receipt, then
|
||||
uses production Canvas pointer events to create the intended sender/receiver/slot
|
||||
draft without a model or source-history mutation.
|
||||
|
||||
The independent reviewer rechecked this exact production change and regression;
|
||||
no remaining medium/high finding was reported in the focused review. Final full
|
||||
desktop tests pass 283/283, strict all-target desktop Clippy passes, optimized
|
||||
desktop build passes, and git diff --check passes. Logs under D:/CodexBuilds are
|
||||
`Capella2-scenario-busy-scroll-before.log`,
|
||||
`Capella2-scenario-busy-scroll-after.log`,
|
||||
`Capella2-scenario-busy-scroll-desktop-tests.log`,
|
||||
`Capella2-scenario-busy-scroll-clippy.log` and
|
||||
`Capella2-scenario-busy-scroll-release.log`.
|
||||
|
||||
Latest Windows artifact, superseding both builds above:
|
||||
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
|
||||
20,349,952 bytes, SHA-256 `CEEB8E277DFC47C0D55DA43F903FCA45AFBF902AFA418B3924375910D699373A`.
|
||||
Native acceptance is pending. Include scrolling while saving in the root's native
|
||||
recovery task before accepting the direct scenario milestone.
|
||||
|
||||
|
||||
## Combined DATA/scenario integration — 2026-09-06
|
||||
|
||||
Merge `dfd2e41259e73ef10a4e29a426569c08eaae215f` integrates `codex/data-contract-integration` at
|
||||
`b35dede75fc8778ea695b9b2c023cfa2a1e49e09` into the isolated scenario branch.
|
||||
The merge applied without conflicts and needed no manual code resolutions or new
|
||||
semantic design. Direct scenario geometry/planners/tests and the existing
|
||||
scenario evidence were unchanged by the merge. App's early passive-scroll handler
|
||||
remains intact alongside DATA's source migration and property-browser handling.
|
||||
The shared field-path API remains compatible: compact leaf-first labels and
|
||||
deterministic sorting now feed the existing bounded scenario value picker, while
|
||||
full qualified labels remain available for detail text.
|
||||
|
||||
Combined verification on the dedicated scenario target: all 286 desktop tests
|
||||
passed, no failures or ignored tests; strict desktop all-target Clippy passed;
|
||||
optimized desktop build passed; staged diff --check passed. All commands used
|
||||
`--locked --offline`. DATA's three new regressions are present together with the
|
||||
283-test scenario suite, including source3 save/reopen and direct gesture/save
|
||||
lifecycle coverage. No DATA checkout, Linux checkout or native application was
|
||||
modified or operated in this integration task.
|
||||
|
||||
Frozen artifact directory: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`.
|
||||
It contains `arcadia-studio.exe`, the three combined verification logs and a
|
||||
`manifest.json` identifying the source checkout, merge commit, target and hash.
|
||||
The copied binary hash was checked against the release target binary.
|
||||
Binary size: 20,411,392 bytes. SHA-256: `8BC031CF6124859D31082BB0FFB0335C406D54E12ECAD8493CE50A23923E5589`.
|
||||
|
||||
Root reports DATA b35dede's Windows native trial and independent focused review
|
||||
passed. This does not transfer native acceptance to the combined direct-scenario
|
||||
workflow. Its Windows/Linux native tasks remain pending, including scroll/resize
|
||||
cancellation, middle/operand insertion, execution spans and save recovery. Root
|
||||
owns final integration into main and native acceptance; process editing remains
|
||||
outside this task.
|
||||
|
||||
|
||||
## Root combined Windows native trial — 2026-09-06
|
||||
|
||||
Frozen dfd2e41 binary above, 1440x900. Native drag inserted Routed request and
|
||||
its correlated reply before the original third call. The Origin/Latitude value
|
||||
route.origin.latitude was authored in the bounded browser. Navigation while
|
||||
pending retained the draft. Create produced57 objects; Undo removed both messages
|
||||
(55), Redo restored them, Save All persisted the pair and qualified value. Saved
|
||||
steps are original call/reply, Routed request/reply, original second call/reply.
|
||||
Valid selected-message reorder retained selection; invalid reply-before-call
|
||||
reorder reported reply_order and added no history. Undo restored the saved order.
|
||||
Inner-canvas scrolling and injected Escape cleared armed source gestures, verified
|
||||
by the next lifeline click starting a new source without completing a draft.
|
||||
The saved app closed normally (exec63022 exit0, no runtime output).
|
||||
|
||||
Native findings: active/inactive tools looked identical, line labels used the
|
||||
shared exchange name despite distinct occurrence names, and Create/Cancel needed
|
||||
inner and outer scrolling. Root is refining those three issues before accepting
|
||||
the milestone. ALT/reuse/resize/save recovery and Linux native remain pending.
|
||||
|
||||
Root UI refinement checks:287 desktop tests passed, strict all-target desktop Clippy passed, optimized Windows desktop build passed. Native verification of this refinement remains pending.
|
||||
|
||||
|
||||
## Refined Windows native results and viewport finding
|
||||
|
||||
c6c07cc Windows frozen binary SHA256
|
||||
4A49AD95C466BACBD1D1EA96FFF983BDA2F5DA9B7DCCBF91C387655CEF88B419,
|
||||
ui-scenario-refined-windows. Native1440x900 and1100x700 show differentiated tool
|
||||
selection, occurrence-first message names and pinned Create/Cancel. A pending
|
||||
Navigation outcome ALT fragment retained its name and route.available guard across
|
||||
width/height resizing. Create produced the fragment and two operands. Native
|
||||
click-click in its first operand explicitly captured that operand's append slot;
|
||||
Available route plus correlated Reply were created on Navigation request. Execution
|
||||
bar ends at that reply. Reply-before-call reorder rejects with reply_order. Save
|
||||
All writes62 objects/5 links/3 checks; exact nested source shows the guarded operand
|
||||
contains only that correlated pair. Undo removes both (60); Redo restores saved62.
|
||||
The saved native trial was closed before the next refinement.
|
||||
|
||||
Native also found the canvas jumps to its start whenever the palette is replaced
|
||||
by a form or restored. Diagnosis: conditional siblings change its Iced widget-tree
|
||||
position. Group the controls as one stable sibling and verify that actual viewport
|
||||
is retained across drawing/form/cancel/create. This is distinct from deliberate
|
||||
new-selection reveal (not implemented yet). Linux287 tests, strict Clippy and
|
||||
optimized build pass for c6c07cc; native Linux remains pending.
|
||||
|
||||
Stable-canvas refinement:287 desktop tests, strict all-target Clippy and optimized Windows build passed. Native retention trial remains pending.
|
||||
|
||||
|
||||
## Stable viewport native closure and process integration
|
||||
|
||||
ae1602a Windows native: reopened saved62-object model; scrolled to the guarded
|
||||
operand, dragged a message before Available route, and verified the pending form
|
||||
retained that guard/call region. Cancel returned to the same region and clean model.
|
||||
Runtime stdout/stderr empty; exec86766 exited0. Frozen ui-scenario-stable-windows
|
||||
SHA256 D0A0D6DAAADA8CEB3700C2A2071ABB15C41386BD9DD49203A7AFCB2869E5C896.
|
||||
This closes the observed palette/form remount jump. New-selection auto-reveal is
|
||||
not added. Compact actions/typed resizing and ALT behavior passed in priorc6c07cc.
|
||||
|
||||
Process merge60d162c adds reviewed8bc564b without source conflicts. Both platforms
|
||||
pass306 desktop tests, strict all-target Clippy and optimized build. New frozen
|
||||
ui-behavior-{windows,linux}-60d162c artifacts have copied62-object models. Linux
|
||||
exec61870/window4852212 launched and is now under root native test; acceptance
|
||||
remains pending. Full release scope is unchanged.
|
||||
|
||||
## Root Linux native scenario trial — 2026-09-06
|
||||
|
||||
Frozen60d162c Wayland1440x900. Native drag in the gap after old reply6 and before
|
||||
Navigation outcome ALT opened Call+correlated Reply while preserving that region.
|
||||
Named Validated route and chose existing Navigation request. Create63→65, Undo65→63,
|
||||
Redo63→65; execution bar ends at its reply. Save All and native reopen passed.
|
||||
Call ef0ef967-041b-4bf2-86b8-977047a3ca90 and correlated reply
|
||||
0005a94e-49c1-41e2-82f9-5a6f285dc604 appear before ALT in saved steps.
|
||||
Model: ui-behavior-linux-60d162c/model/project.arcadia. Linux ALT editing/reuse/
|
||||
reorder/cancellation/minimum layout remain required. Wayland61870 closed0 normally.
|
||||
@@ -42,7 +42,8 @@ Base: `d130013`. Dedicated build target:
|
||||
|
||||
## Verification
|
||||
|
||||
Verified against core `6bb2583`, using only the dedicated desktop target:
|
||||
Verified after the independent scalar review fixes on SEM `6ad3cb`, using only the
|
||||
dedicated desktop target (core behavior remains based on `6bb2583`):
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
|
||||
@@ -51,7 +52,8 @@ cargo clippy --locked -p oa-desktop --all-targets -- -D warnings
|
||||
cargo build --locked -p oa-desktop
|
||||
```
|
||||
|
||||
All **272 desktop tests passed**, including **11 new semantic review tests**.
|
||||
All **277 desktop tests passed**, including **11 original semantic review tests**
|
||||
and **5 scalar review regression tests**.
|
||||
Strict Clippy and the desktop build passed. The existing migration test's header
|
||||
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
|
||||
|
||||
@@ -64,6 +66,25 @@ relationship role/bound candidates with retained identity/endpoints; and exact
|
||||
inherited-annotation cleanup approval, cleared when decisions change. Unselected
|
||||
invalid scalar drafts remain retained without contaminating another candidate.
|
||||
|
||||
The independent scalar review found three gaps, now corrected:
|
||||
|
||||
- Structural scalar edits replace only their authorized subtree. Clearing an
|
||||
invalid selected bound, changing a variant, and adding/removing/reordering a
|
||||
literal retain untouched raw inputs and editor buffers, including invalid
|
||||
inputs belonging to another decision.
|
||||
- Dependent default, reference and port controls read a noncommitting context
|
||||
composed by the same scalar Keep/Take/Candidate assembler as the request. Raw
|
||||
candidates remain separate. Incomplete inputs use their stored shape only for
|
||||
those controls; request creation and Apply still reject invalid selected input.
|
||||
The Boolean-source/Integer-target default reproduction now offers Boolean
|
||||
literals in both directions. Tests also cover Candidate type, Keep reversion,
|
||||
storage-dependent default controls and mechanism-dependent reference choices.
|
||||
- Keyed fact headings display live names with stable aliases or deleted UUIDs.
|
||||
Message annotation headings include the complete qualified path and field.
|
||||
|
||||
The regression tests exercise App events, the actual default-form helpers and
|
||||
button availability, exact source retention before Apply, and one-step undo.
|
||||
|
||||
Built executable:
|
||||
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
|
||||
The build target is idle. No native application was launched by this task.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Semantic integration verification
|
||||
|
||||
Status: verified Windows integration. Merge `332e46b`, formatting correction
|
||||
`c6db9d4`. This checkpoint does not complete native acceptance, structured semantic
|
||||
review or the release. The lead retains overall state and native ownership.
|
||||
|
||||
## Inputs and preserved work
|
||||
|
||||
Merged frozen semantic branch `6ad3cb92df21a5e67c318e31c751fc6a61555ef6` into
|
||||
DATA `1a07475` (combined direct process/scenario production `60d162c`). Every Rust
|
||||
change merged automatically. Only two working-memory documents conflicted: both
|
||||
architectural decisions were retained; DATA's `STATE.md` was preserved unchanged
|
||||
for the lead's next overall checkpoint.
|
||||
|
||||
The integration contains source/schema 5 and explicit historical migration, the
|
||||
closed typed semantic review core, first equal-constructor acknowledgment, three
|
||||
REST query/preview/apply operations, current OpenAPI/reST, and scalar desktop
|
||||
review milestones 1–2. It preserves process continuation, direct scenario editing,
|
||||
stable scenario viewport, compact controls and recursive value browsing.
|
||||
Constructor/keyed/ordered/reset/multi-source desktop authoring remains outside the
|
||||
scalar milestone. No native input or external deployment was performed here.
|
||||
|
||||
## Windows verification
|
||||
|
||||
Windows, Rust/Cargo 1.98.1. Exclusive target:
|
||||
`D:/CodexBuilds/Capella2-data-target`. Logs and generated check artifacts:
|
||||
`D:/CodexBuilds/Capella2-data-target/semantic-integration`.
|
||||
|
||||
Run from `D:/CodexBuilds/Capella2-data-integration`:
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-data-target'
|
||||
cargo test --locked --offline --workspace
|
||||
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
|
||||
cargo build --locked --offline --workspace --release
|
||||
cargo fmt --all -- --check
|
||||
```
|
||||
|
||||
All gates passed. Workspace tests: **718 passed, 0 failed, 1 intentionally ignored**
|
||||
inheritance scaling probe across 39 test summaries: 353 core, 317 desktop, 48 server.
|
||||
This includes the integrated scenario/process/value-browser regressions, all 11
|
||||
scalar semantic desktop cases, source5 migration/writeback and real HTTP operations.
|
||||
Strict all-target workspace Clippy had no warnings. Optimized workspace build passed
|
||||
in 2m11s. Logs: `workspace-tests.log`, `workspace-clippy.log`, `workspace-release.log`.
|
||||
|
||||
The initial formatting check found only the order of two HTTP test module imports.
|
||||
Commit `c6db9d4` applies rustfmt to that file; the repeated whole-workspace check
|
||||
passed (`workspace-fmt-after.log`). No production-code correction was necessary.
|
||||
The ordinary unprivileged Python launcher was denied by the local environment;
|
||||
the authorized scoped verification process succeeded. No blocker remains here.
|
||||
|
||||
## Normative and actual service contracts
|
||||
|
||||
With existing Docutils 0.23 and JSON Schema libraries:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH='D:/CodexBuilds/Capella2-docutils;D:/CodexBuilds/Capella2-contract-tools'
|
||||
python crates/oa-server/tests/semantic_schema.py --check
|
||||
python crates/oa-server/tests/semantic_http_contract.py D:/CodexBuilds/Capella2-data-target/release/arcadia-server.exe
|
||||
```
|
||||
|
||||
The closed generated catalog check passed: 13 families, 84 scalar leaves and 184
|
||||
schemas match checked-in OpenAPI. The HTTP script launched the optimized service
|
||||
on loopback with an isolated temporary store and terminated it after verification.
|
||||
**184 schemas valid; 19 actual/typed values checked against served and checked-in
|
||||
contracts; 9 obsolete/malformed shapes rejected.** It verifies import/migration,
|
||||
query, preview, apply, typed baselines, one-sided selectors and stale rejection.
|
||||
Log: `actual-semantic-http.log`. This is actual executable transport evidence.
|
||||
|
||||
Strict `docs/language.rst` and `docs/api.rst` generation passed with
|
||||
`docutils.core.publish_file`, `writer_name='html5'`, and settings
|
||||
`{'halt_level': 2, 'report_level': 2}`. Outputs went into the evidence directory.
|
||||
Both generated documents exactly match tracked HTML after line-ending
|
||||
normalization. The full core suite also parsed the complete normative examples
|
||||
and exercised typed historical source/JSON, included-file writeback and reopen.
|
||||
|
||||
## Frozen binaries and handoff
|
||||
|
||||
Both optimized executables are frozen in
|
||||
`D:/CodexBuilds/Capella2-data-target/semantic-integration/freeze-c6db9d4`:
|
||||
|
||||
- `arcadia-studio.exe` SHA256
|
||||
`38AF477646BCC7096E5C71D95D827BF24F9E7ECA1E310DCD7D4AD258ED9E55A0`
|
||||
- `arcadia-server.exe` SHA256
|
||||
`C420DFBC30FDA6935E306DF12C310739066389066B878F44D4470E5DD5B1529E`
|
||||
|
||||
The directory also contains `SHA256.txt`. No desktop process was launched. The
|
||||
build target is idle and returned to the lead. Linux integrated checks, native
|
||||
semantic acceptance, independent scalar-review follow-ups and the structured
|
||||
semantic desktop milestone remain separate required work.
|
||||
|
||||
## Scalar review correction integrated on Windows and Linux
|
||||
|
||||
The lead's complete Linux baseline run at `d114a5d` is confirmed from the logs in
|
||||
`D:/CodexBuilds/Capella2-data-linux-target`: **721 passed, 0 failed, 1 intentionally
|
||||
ignored** inheritance probe, across 39 summaries. Strict workspace all-target
|
||||
Clippy and the optimized workspace build also completed successfully. The source
|
||||
logs are `semantic-workspace-test.log`, `semantic-workspace-clippy.log` and
|
||||
`semantic-release-build.log`. These were root-run checks, followed by this task's
|
||||
log inspection; they are not newly rerun workspace checks after the scalar fix.
|
||||
|
||||
Cherry-picked the reviewed scalar correction `5a51a7572e8edee6b0bb85fe6aded3a8380e0edd`
|
||||
as `e7fa061`. The affected forms and semantic files merged automatically, preserving
|
||||
DATA's recursive browser and direct behavior changes. The Linux checkout fetched
|
||||
DATA locally and fast-forwarded to the exact same commit. No core or server code
|
||||
changed. The correction preserves unfinished scoped inputs during structural edits,
|
||||
uses the combined review candidate for dependent controls, and disambiguates keyed
|
||||
fact headings with qualified paths and identity. See `release-semantic-gui.md`.
|
||||
|
||||
Post-fix verification passed on both platforms: **322 desktop tests passed, zero
|
||||
failed or ignored**; strict all-target desktop Clippy; whole-workspace formatting;
|
||||
and optimized desktop builds. Windows build took 1m38s; Linux took 1m33s. The five
|
||||
additional test functions and expanded cases exercise scoped draft retention,
|
||||
dependent type/storage/mechanism controls, exact source/history and qualified
|
||||
fact headings alongside all existing desktop tests.
|
||||
|
||||
Commands used on Windows from DATA, with the existing assigned target:
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-data-target'
|
||||
cargo test --locked --offline -p oa-desktop
|
||||
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline -p oa-desktop --release
|
||||
```
|
||||
|
||||
Matching commands inside Ubuntu 24.04 WSL:
|
||||
|
||||
```sh
|
||||
cd /mnt/d/CodexBuilds/Capella2-data-linux-review
|
||||
export PATH=/home/sorokinoleg/.cache/arcadia/rust/bin:$PATH
|
||||
export CARGO_TARGET_DIR=/mnt/d/CodexBuilds/Capella2-data-linux-target
|
||||
cargo test --locked --offline --config target/linux-cargo.toml -p oa-desktop
|
||||
cargo clippy --locked --offline --config target/linux-cargo.toml -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline --config target/linux-cargo.toml -p oa-desktop --release
|
||||
```
|
||||
|
||||
Logs share the names `scalar-fix-desktop-test.log`,
|
||||
`scalar-fix-desktop-clippy.log`, `scalar-fix-fmt.log` and
|
||||
`scalar-fix-desktop-build.log`. Windows logs are in the target's
|
||||
`semantic-integration` directory; Linux logs are directly in its assigned target.
|
||||
Both command sessions completed with exit status 0.
|
||||
|
||||
Separate native trial directories under
|
||||
`D:/CodexBuilds/Capella2-release1-01a06e2e` now contain frozen `e7fa061` executables:
|
||||
|
||||
- `ui-semantic-windows-e7fa061/arcadia-studio.exe` SHA256
|
||||
`3E46F001711948FB60E3F65C49810DF5C177228D5DFA609ED9DF3F8C96081F34`
|
||||
- `ui-semantic-linux-e7fa061/arcadia-studio` SHA256
|
||||
`94C39037AF47C95FA6EE6515C3EB3B4FFBAC59730286E6EDB5F14B16E7874826`
|
||||
|
||||
Each has a `verification.json` manifest. Both frozen hashes equal their build
|
||||
outputs, and Linux executable access was verified without launching it. Both
|
||||
checkouts and build targets are returned to the lead. No native input was used,
|
||||
and no STATE, TASK_LEDGER, README or other native ledger was staged by this task.
|
||||
Scalar native acceptance and the remaining structured review milestone still
|
||||
belong to the lead's release plan.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Semantic review native evidence
|
||||
|
||||
This is partial acceptance, not release completion. Windows frozen e7fa061,
|
||||
SHA2563E46F001711948FB60E3F65C49810DF5C177228D5DFA609ED9DF3F8C96081F34.
|
||||
Trial exec62786/window2099614; stderr/stdout captured under ui-semantic-windows-e7fa061.
|
||||
Source fixture and exact GUI steps: C:/Users/sorok/AppData/Local/Temp/
|
||||
capella2-semantic-native-fixture-20260906. Original source SHA256
|
||||
54BA9528F6904428B3DE2E41E3D8CAC6861FA652E5F83F11C1516FB3DB40E937.
|
||||
Root edits only windows-scalar.arcadia, preserving the generator's starting source.
|
||||
|
||||
## Observed Windows workflows
|
||||
|
||||
At1440x900, opened source5,9objects/0links/2 expected pending-prose checks.
|
||||
Traceability range card shows prior bounds0..100/default10 and current OA0..20/5,
|
||||
SA1..30/15. Selected Keep System minimum, Take Operational maximum and final default.
|
||||
Invalid99 rejects with invalid_data_default and disables Apply without losing input
|
||||
or any decision. Resized actual window to1100x700; complete error/editor/actions fit.
|
||||
Repair7 restores valid preview. Additional changes reports0. Apply changes SA to
|
||||
1..20/default7 and preserves OA0..20/default5. Native text inspection shows exact
|
||||
values and leading comment. One Undo returns All changes saved, Redo returns the
|
||||
review, and Save All writes source. Disk inspection matches the native result.
|
||||
|
||||
At1100x700, Enabled flag Type Take Operational proposes Boolean over original
|
||||
System Integer. Default Set final value then Set literal default produces the
|
||||
Boolean checkbox/false value directly. Validated preview and Apply succeed; Save
|
||||
All persists it. This exercises combined type context through actual controls.
|
||||
|
||||
## Remaining checks and findings
|
||||
|
||||
Reverse decisions, raw invalid bound scoped clearing, named-type mapping, deleted
|
||||
historical identities, saved reopen and Linux remain required. Error evidence above
|
||||
covers a normal validation message, not arbitrarily large diagnostics.
|
||||
Native found compact Traceability toolbar clips Artifact after the semantic button;
|
||||
semantic_gui_design owns a narrow wrapping correction. No scalar-modal defect found
|
||||
in the tested paths. Structured review is a separate incomplete milestone.
|
||||
Reference in New Issue
Block a user