Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb01ae807 | ||
|
|
c149f2f74e | ||
|
|
7512ac8950 | ||
|
|
9c3184bf21 | ||
|
|
f73551fddc | ||
|
|
5a51a7572e |
Generated
-6
@@ -2155,16 +2155,10 @@ 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 libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev 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 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.
|
||||
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.
|
||||
|
||||
**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,18 +118,14 @@ 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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -146,4 +142,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, 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, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
|
||||
@@ -13,14 +13,5 @@ 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,4 +1,3 @@
|
||||
mod hosted;
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
document::Document,
|
||||
@@ -13,219 +12,8 @@ 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;
|
||||
@@ -281,7 +69,6 @@ impl std::fmt::Display for Choice {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Pending {
|
||||
HostedOpen,
|
||||
New,
|
||||
Demo,
|
||||
Open,
|
||||
@@ -301,7 +88,6 @@ 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),
|
||||
@@ -345,7 +131,6 @@ 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,
|
||||
@@ -363,8 +148,6 @@ 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,
|
||||
@@ -413,7 +196,6 @@ 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,
|
||||
@@ -529,7 +311,6 @@ impl App {
|
||||
Kind::Activity
|
||||
},
|
||||
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
||||
hosted: Default::default(),
|
||||
busy: false,
|
||||
pending: None,
|
||||
delete_pending: false,
|
||||
@@ -799,14 +580,9 @@ 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) = scenario_selection.or(selected) {
|
||||
if let Some(id) = selected {
|
||||
self.selected = id;
|
||||
}
|
||||
self.sync();
|
||||
@@ -849,10 +625,7 @@ 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;
|
||||
@@ -914,12 +687,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let base = crate::context_menu::Popup::new(
|
||||
crate::views::view(self),
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, base)
|
||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
||||
.into()
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -969,19 +738,6 @@ 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;
|
||||
@@ -1031,7 +787,6 @@ impl App {
|
||||
&& !matches!(
|
||||
message,
|
||||
Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(_, _)
|
||||
| Message::SavePath(_)
|
||||
@@ -1051,10 +806,6 @@ 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();
|
||||
}
|
||||
@@ -1079,7 +830,6 @@ impl App {
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(_, _)
|
||||
| Message::SavePath(_)
|
||||
@@ -1087,8 +837,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
);
|
||||
if !modal_event
|
||||
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|
||||
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|
||||
|| (self.file_prompt.is_some()
|
||||
&& !matches!(
|
||||
message,
|
||||
@@ -1146,11 +895,6 @@ 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
|
||||
@@ -1193,117 +937,8 @@ 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()
|
||||
@@ -1505,18 +1140,6 @@ 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)
|
||||
@@ -1936,9 +1559,6 @@ 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) => {}
|
||||
@@ -2510,9 +2130,6 @@ 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;
|
||||
@@ -2564,9 +2181,6 @@ 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();
|
||||
@@ -2670,16 +2284,7 @@ impl App {
|
||||
Err(e) => e,
|
||||
};
|
||||
}
|
||||
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::WindowResized(size) => self.window_size = size,
|
||||
Message::CanvasResized(size) => {
|
||||
let first_measurement = self.canvas_size.is_none();
|
||||
let size_changed = self.canvas_size != Some(size);
|
||||
@@ -2905,7 +2510,6 @@ 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()
|
||||
@@ -3054,20 +2658,10 @@ impl App {
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
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();
|
||||
}
|
||||
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);
|
||||
@@ -3695,9 +3289,7 @@ 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)
|
||||
// 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)
|
||||
&& decoded.migration.is_some()
|
||||
{
|
||||
return Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
@@ -3782,97 +3374,6 @@ 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,18 +322,14 @@ 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::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
|
||||
behavior::topology::CanvasEvent::Zoom(context, None),
|
||||
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),
|
||||
] {
|
||||
send(app, Message::TopologyCanvas(event));
|
||||
send(app, Message::Behavior(event));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.behavior.has_pending());
|
||||
|
||||
@@ -1,568 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,817 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
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()));
|
||||
}
|
||||
+58
-1668
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,8 @@
|
||||
//! Process/chain authoring and projection use contextual occurrences and explicit
|
||||
//! SequenceLinks. Presentation order never invents control-flow relationships.
|
||||
use super::*;
|
||||
use iced::widget::{Column, text_input};
|
||||
use iced::widget::{Column, 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 {
|
||||
@@ -48,8 +40,9 @@ pub enum Event {
|
||||
Guard(String),
|
||||
AlternateGuard(String),
|
||||
Control(ControlChoice),
|
||||
Search(usize, String),
|
||||
Page(usize, usize),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ControlChoice(pub ControlKind);
|
||||
@@ -66,25 +59,11 @@ 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,
|
||||
@@ -120,6 +99,9 @@ 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
|
||||
@@ -145,9 +127,6 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
owner,
|
||||
tool,
|
||||
name: String::new(),
|
||||
@@ -175,11 +154,6 @@ 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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -189,13 +163,12 @@ impl State {
|
||||
}
|
||||
|
||||
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
|
||||
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.)
|
||||
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.)
|
||||
})
|
||||
}
|
||||
}
|
||||
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
|
||||
@@ -274,24 +247,13 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
|
||||
})
|
||||
.map(|e| Choice {
|
||||
id: e.id,
|
||||
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
|
||||
label: format!("{} · {}", e.name, e.alias),
|
||||
})
|
||||
.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)
|
||||
@@ -432,40 +394,6 @@ 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,
|
||||
@@ -490,6 +418,7 @@ 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));
|
||||
}
|
||||
@@ -530,6 +459,12 @@ 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,
|
||||
@@ -617,47 +552,30 @@ impl Draft {
|
||||
commands.push(Command::CreateElement(e));
|
||||
(id, id)
|
||||
};
|
||||
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(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(after) = self.after {
|
||||
commands.push(link(p, last, after, ""));
|
||||
}
|
||||
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));
|
||||
// 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)),
|
||||
);
|
||||
}
|
||||
Ok(unique_batch(p, commands))
|
||||
}
|
||||
}
|
||||
@@ -680,6 +598,23 @@ 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
|
||||
@@ -687,9 +622,307 @@ fn event(e: Event) -> Message {
|
||||
super::Event::Topology(e)
|
||||
})
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
view::view(app, owner, out)
|
||||
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, 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()
|
||||
@@ -709,7 +942,6 @@ struct GraphNode {
|
||||
source: Option<Id>,
|
||||
preview: Vec<(Id, String)>,
|
||||
preview_links: Vec<(Id, Id)>,
|
||||
preview_total: usize,
|
||||
}
|
||||
struct GraphEdge {
|
||||
id: Id,
|
||||
@@ -719,21 +951,15 @@ struct GraphEdge {
|
||||
points: Vec<Point>,
|
||||
label: Rectangle,
|
||||
}
|
||||
struct Graph<'a> {
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
scroll_offset: iced::Vector,
|
||||
struct Graph {
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
}
|
||||
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 {
|
||||
impl Graph {
|
||||
fn new(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
@@ -774,17 +1000,17 @@ impl<'a> Graph<'a> {
|
||||
fallback += 1;
|
||||
}
|
||||
}
|
||||
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
|
||||
let mut rows: BTreeMap<usize, usize> = 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_insert(24.);
|
||||
let slot = rows.entry(rank).or_default();
|
||||
let mut node = GraphNode {
|
||||
id: e.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(24. + rank as f32 * 420., *slot),
|
||||
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
|
||||
Size::new(270., 104.),
|
||||
),
|
||||
title: e.name.clone(),
|
||||
@@ -793,8 +1019,8 @@ impl<'a> Graph<'a> {
|
||||
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, .. } => {
|
||||
@@ -825,19 +1051,11 @@ impl<'a> Graph<'a> {
|
||||
.map(|id| format!("ref · {}", p.named(id)))
|
||||
.unwrap_or("Choose source in Properties".into());
|
||||
if let Some(source) = sequence {
|
||||
let contributions = contributions(p, *source);
|
||||
node.preview_total = contributions.len();
|
||||
node.preview = contributions
|
||||
node.preview = contributions(p, *source)
|
||||
.into_iter()
|
||||
.take(if expanded.contains(&e.id) {
|
||||
usize::MAX
|
||||
} else {
|
||||
4
|
||||
})
|
||||
.take(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)
|
||||
@@ -853,13 +1071,11 @@ impl<'a> Graph<'a> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
*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()
|
||||
@@ -873,14 +1089,11 @@ impl<'a> Graph<'a> {
|
||||
.rect;
|
||||
let start = Point::new(a.x + a.width, a.center_y());
|
||||
let end = Point::new(b.x, b.center_y());
|
||||
// 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 points = if end.x > start.x {
|
||||
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 * 42.;
|
||||
let below = size.height + 24. + i as f32 * 30.;
|
||||
vec![
|
||||
start,
|
||||
Point::new(start.x + 18., start.y),
|
||||
@@ -894,7 +1107,7 @@ impl<'a> Graph<'a> {
|
||||
RelationBody::SequenceLink { guard, .. } => guard.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let label_at = if forward {
|
||||
let label_at = if end.x > start.x {
|
||||
Point::new(end.x - 138., end.y - 43.)
|
||||
} else {
|
||||
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
|
||||
@@ -915,9 +1128,6 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
interaction: None,
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
size,
|
||||
@@ -994,24 +1204,47 @@ 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 = Input;
|
||||
impl canvas::Program<Message> for Graph {
|
||||
type State = ();
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut Input,
|
||||
_: &mut (),
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
self.input(state, event, bounds, cursor)
|
||||
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
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
state: &Input,
|
||||
_: &(),
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
@@ -1094,7 +1327,7 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
if n.source.is_some() {
|
||||
draw_text(
|
||||
&mut f,
|
||||
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
|
||||
"Open source →".into(),
|
||||
Point::new(n.rect.x + 12., n.rect.y + 94.),
|
||||
11.,
|
||||
style::TEAL,
|
||||
@@ -1167,12 +1400,11 @@ 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 {
|
||||
@@ -1246,9 +1478,6 @@ 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(),
|
||||
@@ -1437,7 +1666,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!(
|
||||
@@ -1548,26 +1777,10 @@ mod tests {
|
||||
draft: Some(branch(owner, a, b, before, after)),
|
||||
..State::default()
|
||||
};
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: Id::nil(),
|
||||
label: "Missing".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.update(&p, Event::Guard(String::new())).unwrap();
|
||||
assert!(state.update(&p, Event::Submit).is_err());
|
||||
assert!(state.draft.is_some());
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Target(Choice {
|
||||
id: a,
|
||||
label: "Work".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_some());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
@@ -1623,8 +1836,9 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut graph = Graph::new(&p, owner, owner);
|
||||
let mut state = State::default();
|
||||
state.viewport(Size::new(486., 460.));
|
||||
state.zoom(None);
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(486., 460.)))
|
||||
.unwrap();
|
||||
graph.scale = state.scale(graph.size, 500.);
|
||||
assert!(graph.size.width * graph.scale <= 462.1);
|
||||
assert!(graph.size.height * graph.scale <= 450.1);
|
||||
@@ -1635,7 +1849,7 @@ mod tests {
|
||||
);
|
||||
let result = canvas::Program::update(
|
||||
&graph,
|
||||
&mut Input::default(),
|
||||
&mut (),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
|
||||
mouse::Cursor::Available(click),
|
||||
@@ -1644,10 +1858,12 @@ mod tests {
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
|
||||
state.zoom(Some(1.));
|
||||
state.viewport(Size::new(800., 460.));
|
||||
state.update(&p, Event::Zoom(1.)).unwrap();
|
||||
state
|
||||
.update(&p, Event::Viewport(Size::new(800., 460.)))
|
||||
.unwrap();
|
||||
assert_eq!(state.scale(graph.size, 500.), 1.);
|
||||
state.zoom(None);
|
||||
state.update(&p, Event::Fit).unwrap();
|
||||
assert!(state.scale(graph.size, 500.) < 1.);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
//! 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
@@ -1,138 +0,0 @@
|
||||
use super::*;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::{Rectangle, Vector};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
scrolls: Vec<(Option<widget::Id>, Rectangle, Vector)>,
|
||||
actions: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
operate(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("test-process-actions")) {
|
||||
self.actions = Some(bounds);
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
self.scrolls.push((id.cloned(), bounds, translation));
|
||||
}
|
||||
}
|
||||
fn workspace(pending: bool) -> iced::Element<'static, Message, iced::Theme, ()> {
|
||||
let diagram = scrollable(iced::widget::Space::new().width(500).height(1200))
|
||||
.id("test-process-page")
|
||||
.height(Fill)
|
||||
.into();
|
||||
let form = if pending {
|
||||
editor_panel(
|
||||
container(iced::widget::Space::new().height(32))
|
||||
.id("test-process-actions")
|
||||
.into(),
|
||||
iced::widget::Space::new().height(1400).into(),
|
||||
)
|
||||
} else {
|
||||
iced::widget::Space::new().height(0).into()
|
||||
};
|
||||
panes(diagram, form, pending)
|
||||
}
|
||||
fn geometry(
|
||||
view: &mut iced::Element<'_, Message, iced::Theme, ()>,
|
||||
tree: &mut widget::Tree,
|
||||
size: Size,
|
||||
) -> (layout::Node, Geometry) {
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, size));
|
||||
let mut geometry = Geometry::default();
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &(), &mut geometry);
|
||||
(node, geometry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible() {
|
||||
// Actual available center-pane sizes at the minimum/default supported windows.
|
||||
// Space represents arbitrarily long diagram/form content; the production
|
||||
// pane/card builders and real Iced layout/scroll operations are exercised.
|
||||
for size in [Size::new(500., 570.), Size::new(840., 770.)] {
|
||||
let mut view = workspace(false);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let (node, _) = geometry(&mut view, &mut tree, size);
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("test-process-page"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
view = workspace(true);
|
||||
tree.diff(view.as_widget());
|
||||
let (node, before) = geometry(&mut view, &mut tree, size);
|
||||
let page = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("test-process-page")))
|
||||
.unwrap();
|
||||
let form = before
|
||||
.scrolls
|
||||
.iter()
|
||||
.find(|s| s.0 == Some(widget::Id::new("process-form")))
|
||||
.unwrap();
|
||||
assert_eq!(node.size(), size);
|
||||
assert!(
|
||||
form.1.height >= 200.,
|
||||
"usable form viewport: {size:?} {:?}",
|
||||
form.1
|
||||
);
|
||||
assert!(form.1.y >= page.1.y + page.1.height);
|
||||
assert!(form.1.y + form.1.height <= size.height);
|
||||
assert_eq!(page.2.y, 180., "opening a form retains the page scroll");
|
||||
let actions = before.actions.unwrap();
|
||||
assert!(actions.y >= page.1.y + page.1.height);
|
||||
assert!(actions.y + actions.height <= form.1.y);
|
||||
let mut scroll = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("process-form"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(600.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut scroll);
|
||||
let (_, after) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(
|
||||
after.actions,
|
||||
Some(actions),
|
||||
"Create/Cancel stay pinned during form scrolling"
|
||||
);
|
||||
assert_eq!(
|
||||
after.scrolls[0].2.y, 180.,
|
||||
"form scrolling does not move the diagram page"
|
||||
);
|
||||
assert!(
|
||||
after
|
||||
.scrolls
|
||||
.iter()
|
||||
.any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.)
|
||||
);
|
||||
view = workspace(false);
|
||||
tree.diff(view.as_widget());
|
||||
let (_, closed) = geometry(&mut view, &mut tree, size);
|
||||
assert_eq!(
|
||||
closed.scrolls[0].2.y, 180.,
|
||||
"cancel retains the page scroll"
|
||||
);
|
||||
assert_eq!(closed.scrolls[0].1.height, size.height);
|
||||
}
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
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))),
|
||||
);
|
||||
}
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
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);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
),
|
||||
form_panel,
|
||||
pending,
|
||||
)
|
||||
}
|
||||
|
||||
/// Keep the diagram mounted while an independent editor uses the remaining
|
||||
/// workspace height. The editor must never be clipped by the page scrollable.
|
||||
fn panes<'a, R: iced::advanced::Renderer + 'a>(
|
||||
diagram: iced::Element<'a, Message, iced::Theme, R>,
|
||||
form: iced::Element<'a, Message, iced::Theme, R>,
|
||||
pending: bool,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
column![
|
||||
container(diagram).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
Fill
|
||||
}),
|
||||
container(form).width(Fill).height(if pending {
|
||||
iced::Length::FillPortion(1)
|
||||
} else {
|
||||
iced::Length::Shrink
|
||||
}),
|
||||
]
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
actions: iced::Element<'a, Message, iced::Theme, R>,
|
||||
fields: iced::Element<'a, Message, iced::Theme, R>,
|
||||
) -> iced::Element<'a, Message, iced::Theme, R> {
|
||||
container(
|
||||
container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8))
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(iced::Padding {
|
||||
top: 0.,
|
||||
right: 18.,
|
||||
bottom: 18.,
|
||||
left: 18.,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
#[path = "layout_tests.rs"]
|
||||
mod layout_tests;
|
||||
@@ -11,11 +11,10 @@ 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.picker_label)
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +49,8 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ");
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
@@ -63,97 +63,54 @@ 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!(
|
||||
"{} / {}{direction}",
|
||||
project.named(entry.item),
|
||||
qualified.join(" → ")
|
||||
),
|
||||
picker_label,
|
||||
label: format!("{} / {qualified}{direction}", project.named(entry.item)),
|
||||
}
|
||||
}
|
||||
|
||||
// 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(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(),
|
||||
))
|
||||
Ok(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();
|
||||
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,
|
||||
})
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
}
|
||||
})
|
||||
})
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||
@@ -209,22 +166,20 @@ pub fn children(
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
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(),
|
||||
))
|
||||
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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -527,33 +482,4 @@ 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,6 @@ 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,
|
||||
@@ -49,7 +43,6 @@ pub struct Draft {
|
||||
pub semantic_groups: bool,
|
||||
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -90,14 +83,11 @@ impl Draft {
|
||||
semantic_groups: false,
|
||||
constructor_drafts: BTreeMap::new(),
|
||||
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 {
|
||||
@@ -931,15 +921,10 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
..
|
||||
},
|
||||
..
|
||||
} => 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()
|
||||
}),
|
||||
} => project
|
||||
.relations
|
||||
.get(id)
|
||||
.and_then(|r| crate::data_values::choices(project, r, message_kind).ok()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -330,152 +330,97 @@ fn message_value<'a>(
|
||||
} => project.relations.get(id).map(|r| (r, message_kind)),
|
||||
_ => None,
|
||||
});
|
||||
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(|choice| crate::data_values::parent(&choice.key)),
|
||||
page: 0,
|
||||
});
|
||||
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);
|
||||
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)) = 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::FieldValueBrowse(path.into(), None, 0)),
|
||||
);
|
||||
}
|
||||
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)
|
||||
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
|
||||
.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))
|
||||
})
|
||||
.placeholder("Choose a field here…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
if pages > 1 {
|
||||
out = out.push(
|
||||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
if let Some(choice) = &selected
|
||||
&& !choices.iter().any(|c| c.key == choice.key)
|
||||
{
|
||||
choices.push(choice.clone());
|
||||
}
|
||||
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())
|
||||
{
|
||||
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(
|
||||
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,
|
||||
)),
|
||||
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),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
out = out.push(
|
||||
text("Choose a message exchange to browse its carried fields.")
|
||||
.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))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldValuePath(path.into(), parent)),
|
||||
);
|
||||
}
|
||||
match crate::data_values::children(project, exchange, phase, &key) {
|
||||
Ok(children) if !children.is_empty() => {
|
||||
let path = path.to_owned();
|
||||
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),
|
||||
);
|
||||
}
|
||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(expression) = value.get("expression") {
|
||||
out = out.push(super::property(
|
||||
@@ -490,25 +435,6 @@ 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::*;
|
||||
@@ -815,25 +741,6 @@ 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 {
|
||||
@@ -897,43 +804,6 @@ 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();
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Topic {
|
||||
Self::Source => "Textual editing & Git",
|
||||
Self::Recovery => "Checks & error recovery",
|
||||
Self::Files => "Files & keyboard shortcuts",
|
||||
Self::Server => "Hosted models & REST API",
|
||||
Self::Server => "REST API & automation",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,42 +751,16 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Server => {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
|
||||
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
|
||||
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",
|
||||
"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",
|
||||
"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",
|
||||
),
|
||||
walkthrough(
|
||||
"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",
|
||||
"Build a safe editing client",
|
||||
&[
|
||||
"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.",
|
||||
@@ -795,6 +769,10 @@ 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.",
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,609 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
//! 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,7 +13,6 @@ mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod hosted;
|
||||
mod model_menu;
|
||||
mod navigation;
|
||||
mod port_edit;
|
||||
|
||||
@@ -55,14 +55,22 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
]
|
||||
.spacing(10)
|
||||
.width(220),
|
||||
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
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),
|
||||
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(app.save_label()).size(12))
|
||||
button(text("Save all").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/semantic_transitions.rs"]
|
||||
mod semantic_transitions;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
#[path = "http/semantic_transitions.rs"]
|
||||
mod semantic_transitions;
|
||||
|
||||
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 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 |
|
||||
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, 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, documented REST commands/queries and optional hosted desktop editing | Save and server review are explicit; there is no automatic synchronization or merge |
|
||||
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
|
||||
|
||||
## 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, 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.
|
||||
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.
|
||||
|
||||
## 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. 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
+2
-34
@@ -85,41 +85,9 @@ 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.
|
||||
|
||||
## Edit hosted models in the desktop
|
||||
## Desktop and automation integration
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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,11 +79,6 @@ 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
|
||||
|
||||
+6
-7
@@ -27,13 +27,12 @@ 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 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.
|
||||
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.
|
||||
|
||||
Continue source authoring improvements only through the production parser/updater.
|
||||
The23-test experiment is evidence, not architecture: independent probes exposed
|
||||
|
||||
+213
-77
@@ -1,92 +1,228 @@
|
||||
# Current release state — 2026-09-06
|
||||
# First release — current engineering state
|
||||
|
||||
The first-release goal remains active and incomplete. The authoritative objective
|
||||
is attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md. PLAN and
|
||||
TASK_LEDGER retain release scope. Do not equate the accepted0.2 prototype or scoped
|
||||
verification with complete OA/SA/source/REST/UI parity.
|
||||
## Objective and phase
|
||||
|
||||
## Checkouts and ownership
|
||||
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.
|
||||
|
||||
- Main C:/Users/sorok/OneDrive/Documents/ChatGPT/Capella 2 is the continuation pointer
|
||||
checkout, branch codex/release1-engineering, last pointer04316a7.
|
||||
- Active integration D:/CodexBuilds/Capella2-data-integration,
|
||||
codex/data-contract-integration, production67ed8e0. Root owns index and data-target.
|
||||
- Linux D:/CodexBuilds/Capella2-data-linux-review, codex/data-linux-review,
|
||||
43dd566 equivalent source. Root owns index and data-linux-target; currently idle.
|
||||
- SEM D:/CodexBuilds/Capella2-semantic-review, codex/semantic-review,
|
||||
f73551f. semantic_gui_design owns remaining constructor families in semantic_review,
|
||||
minimal forms adapters and its GUI ledger; semantic-desktop-target is exclusive.
|
||||
- HOST D:/CodexBuilds/Capella2-hosted-desktop, codex/hosted-desktop, base1086e54.
|
||||
semantic_integration owns hosted client/lifecycle, Cargo/index and exactly three
|
||||
views.rs header hooks. Its child owns client.rs only. scenario-target is exclusive.
|
||||
- semantic_http_review closed MessageKind review; Temp target and SEM target released.
|
||||
Root is sole native operator. No worker may issue native input.
|
||||
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.
|
||||
|
||||
## Integrated behavior and evidence
|
||||
## Accepted foundations
|
||||
|
||||
R6 data contracts, direct scenario/process editing, source5 typed core reconciliation,
|
||||
REST typed preview/apply and scalar GUI are integrated. Milestone3 has only the
|
||||
MessageKind/TimerExpression constructor group complete; other constructor/keyed/order/
|
||||
reset/artifact/multi-source graphical decisions remain required.
|
||||
- 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
|
||||
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.
|
||||
|
||||
Full source5 baseline passed Windows718 and Linux721 workspace tests, one intentional
|
||||
benchmark ignored on each; strict workspace Clippy and optimized desktop/server builds.
|
||||
Real optimized loopback REST checked184 schemas,19 actual typed values and9 malformed
|
||||
rejections. Current67ed8e0/43dd566 adds desktop-only changes:335 complete desktop tests,
|
||||
strict all-target Clippy, whole-workspace fmt and optimized builds pass on both.
|
||||
Linux sessions/logs are recorded in release-semantic-native.md.
|
||||
## Newly accepted state workspace
|
||||
|
||||
Independent scalar review closed3 P2 findings (scoped invalid buffers, combined
|
||||
candidate context, historical identity). MessageKind review closed22 tests plus288
|
||||
independent differential cases with no material finding. Process camera/form-scroll
|
||||
fixcf33785 and mounted-owner reset8c60008 passed actual Iced red/green regressions,
|
||||
327 desktop platform gates and independent26 observer/process checks. Native final
|
||||
camera/owner verification remains pending; see release-process-scroll-identity.md.
|
||||
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.
|
||||
|
||||
## Native evidence and environment
|
||||
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.
|
||||
|
||||
Windows scalar e7fa061 trial62786 closed0 normally, empty stderr.1440x900 and1100x700:
|
||||
mixed Keep/Take/Candidate, invalid default99 retention/repair7, exact source/comment,
|
||||
Undo/Redo/Save; Boolean type+default candidate; missing reference mapping rejection,
|
||||
explicit named-type pair and valid Integer1 default; distinct deleted UUID headings;
|
||||
saved reopen; reverse System→OA minimum/default and atomic Undo. See
|
||||
release-semantic-native.md. Working file windows-scalar.arcadia contains the three
|
||||
forward reviews; reverse was undone. Linux and scoped raw-bound clearing remain.
|
||||
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.
|
||||
|
||||
Prior Linux Wayland60d162c actual1440x900: numeric constraints/defaults, recursive
|
||||
qualified value browser, middle Call/Reply scenario and direct SA chain, Undo/Redo,
|
||||
source/includes/Save. Windows reopened Linux69-object chain, added named Choice and
|
||||
Two paths at1100x700, Undo/Redo/Save74objects/13links/5 incomplete-model checks. Details
|
||||
in release-data-verification.md, release-scenario-editing.md, release-process-editing.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.
|
||||
|
||||
New frozen ui-semantic-groups-{windows,linux}-67ed8e0 include current process fixes
|
||||
and MessageKind slice. Hashes and source commits in release-semantic-native.md.
|
||||
Windows freeze/model is an isolated74-object process copy. Neither new binary has
|
||||
been natively operated yet. Re-inventory windows; scalar2099614 is now closed.
|
||||
Original Capella395654 remains available. Do not touch protected user windows.
|
||||
## Active R6 implementation and next actions
|
||||
|
||||
Wayland works with WGPU_BACKEND=vulkan and
|
||||
VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json. Compact WSLg frame resizing
|
||||
is unverified. Optional X11 is blocked by missing libxkbcommon-x11-0; sudo requires
|
||||
user password. User was told to run sudo apt-get install libxkbcommon-x11-0 in Ubuntu.
|
||||
No authentication bypass; Windows/Wayland/REST progress is not blocked.
|
||||
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.
|
||||
|
||||
## Exact next actions
|
||||
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.
|
||||
|
||||
1. Launch current frozen native build; verify preserved Fit/manual scale, remote
|
||||
scroll through forms, owner-to-owner reset, compact branch labels and toolbar.
|
||||
2. Finish Linux scalar and native MessageKind/TimerExpression acceptance, then
|
||||
remaining advanced OA/process/scenario cases and pending raw-bound cases.
|
||||
3. Integrate independently reviewed remaining constructor/keyed/order/reset/artifact/
|
||||
multi-source GUI slices; preserve core prepared plans and exact source history.
|
||||
4. Integrate HOST open/publish/save/conflict lifecycle and operate actual
|
||||
desktop→loopback API→desktop including offline retention and Save as local.
|
||||
5. Complete remaining coverage/filter, Home/recent/recovery, offline help and guided
|
||||
engineering task requirements; fresh final reviews, full platform/MSRV and packaging.
|
||||
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.
|
||||
|
||||
Build workflow: 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.
|
||||
Use dedicated targets. WSL Ubuntu24.04 Rust1.98.1 PATH starts
|
||||
/home/sorokinoleg/.cache/arcadia/rust/bin; add --config target/linux-cargo.toml.
|
||||
No remote push/publication, usage reset or broad target deletion is authorized.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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. 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.
|
||||
|
||||
## Environment and native ownership
|
||||
|
||||
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.
|
||||
|
||||
## R3 core/source frozen checkpoint (2026-09-06)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
+10
-47
@@ -37,17 +37,11 @@ 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.
|
||||
- Accepted-main R3 existing-target reconciliation remains name/description only;
|
||||
isolated typed core/source/API are implemented, with desktop integration active. R6 data shape
|
||||
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
||||
expansion is active implementation, not accepted functionality.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Assumptions and open questions
|
||||
|
||||
@@ -57,11 +51,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 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.
|
||||
- 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.
|
||||
|
||||
## Decisions and current strategy
|
||||
|
||||
@@ -74,35 +68,4 @@ 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. 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.
|
||||
|
||||
2026-09-06: DATA67ed8e0/Linux43dd566 integrates bounded MessageKind/TimerExpression
|
||||
review.335 desktop tests and strict checks/build pass per platform; independent22
|
||||
module tests+288 differential cases close no findings. Windows native scalar now
|
||||
includes named mapping/default, historical identities, reopen and reverse Undo;
|
||||
trial closed0/empty stderr. Exact scope and pending native cases:
|
||||
release-semantic-native.md. Hosted desktop implementation is isolated in
|
||||
Capella2-hosted-desktop; all release completion claims remain pending.
|
||||
review and runtime progress; no external blocker exists.
|
||||
|
||||
@@ -1,152 +1,50 @@
|
||||
# R6 data contracts — native verification in progress
|
||||
# R6 data contracts — verification in progress
|
||||
|
||||
R6 is not accepted. Main remains at a8f451e. The full first-release goal remains
|
||||
active and incomplete. User explicitly reauthorized native testing after Escape.
|
||||
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.
|
||||
|
||||
## Frozen production and automated evidence
|
||||
## Current evidence
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
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.
|
||||
## Active checks and remaining gates
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,274 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,311 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,158 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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 edited 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
|
||||
|
||||
Raw invalid bound scoped clearing 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.
|
||||
|
||||
## Mapping, history, reopen and reverse review — 2026-09-06
|
||||
|
||||
At1100x700, selected Take Operational Type on OA Selected count → SA Selected count.
|
||||
Preview rejected the missing explicit OA Count range correspondence and disabled
|
||||
Apply. Reference pairs OA Count range → SA Count range resolved it. The proposed
|
||||
named type was @sa_count_range; a candidate literal default initialized to Integer1,
|
||||
using the destination range minimum. Apply and Save All persisted both atomically.
|
||||
Disk source confirms Named @sa_count_range and Integer1, with0 model checks.
|
||||
|
||||
Selected the record trace and inspected both historical deleted children. Detail
|
||||
headings distinguish full UUIDs ending2329 and232a, despite identical former names;
|
||||
prior OA presence and current absence are retained. Cancel changed nothing.
|
||||
|
||||
Opened the saved windows-scalar.arcadia through the native file dialog. It reopened
|
||||
with9objects/0links/0checks, original comment, accepted values and reviewed history.
|
||||
On the range trace, switched to System→Operational and included unchanged facts.
|
||||
Previously reviewed/current minimum values were OA0/SA1 and defaults OA5/SA7.
|
||||
Take System minimum and default previewed1/7; Apply selected the OA destination.
|
||||
Native source showed OA default7. One Undo restored OA default5 and All changes
|
||||
saved, retaining the earlier three saved reviews. The reverse exercise was left
|
||||
undone intentionally. The separate windows-reverse.arcadia remains an unused copy.
|
||||
|
||||
Closed trial62786 normally, exit0. Captured stderr was empty. No new scalar defect
|
||||
was found. This still does not establish complete structured review acceptance.
|
||||
|
||||
## Integrated message-kind group verification — 2026-09-06
|
||||
|
||||
SEM f73551f integrates as DATA67ed8e0 and Linux43dd566. Both platforms passed335
|
||||
complete desktop tests, strict all-target desktop Clippy, workspace formatting and
|
||||
optimized desktop build. Core/server are unchanged from the full integrated gates.
|
||||
Independent review on a fresh Temp archive passed22 semantic-review tests and a
|
||||
288-case differential matrix against core (all6 kinds, both directions, baseline
|
||||
and no-baseline, Keep/Take with unrelated Header candidate). No material findings.
|
||||
Temp probe: capella2-message-review-f73551f/semantic_review/message_kind_tests.rs
|
||||
under the desktop crate. Root native checks of the new group remain required.
|
||||
|
||||
Frozen binaries under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||
- ui-semantic-groups-windows-67ed8e0/arcadia-studio.exe, SHA256
|
||||
CC741CC0FE21B7873E545DE7763E74CCF18AB8401D1EEBD8F90DA493B7262DD8.
|
||||
- ui-semantic-groups-linux-67ed8e0/arcadia-studio, SHA256
|
||||
B50BD6462C239C52AEF93FD235FC3652901C519E5289938DD881523357C891D4.
|
||||
|
||||
Windows build/test session97666 and Linux2903 exited0. Linux logs are
|
||||
message-kind-{test,clippy,build}.log in Capella2-data-linux-target. Frozen manifests
|
||||
record source/platform/hash. The Windows freeze contains an isolated copy of the
|
||||
74-object process fixture for native camera, owner-scroll and branch checks.
|
||||
Reference in New Issue
Block a user