Make participant ports and hierarchical model navigation discoverable
Add focused containment browsing, contextual paged search, clear outline depth and direct component-port creation and connection actions. Share exact boundary feasibility checks to avoid whole-project validation for each picker candidate. Preserve source/property drafts and interrupted-save recovery state, document source-project behavior, and record Windows/Linux native verification and independent review fixes.
This commit is contained in:
@@ -45,13 +45,15 @@ The example opens in an architecture view with work nested inside its performers
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
|
||||
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
|
||||
3. Use **Create relationship** for allocations, interactions/functional exchanges, capability involvement, mission links, state transitions and interface relationships. Add exchange items, typed data, ports and provided/required interfaces in **Data & interfaces**.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Select the port and choose **Connect boundary exchange**; the picker identifies compatible ports by owner, name and alias. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions. Use **Create relationship** for allocations, functional exchanges, capability involvement, mission links, state transitions and interface relationships; **Data & interfaces** holds reusable payloads and contracts.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. State machines contain regions and states/modes with guarded transitions, availability and state-action links. Conditions are descriptive, not executable simulation code. Creating records in Behavior or Data preserves your architecture layouts; **Show in this diagram** adds an explicit placement when desired.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
|
||||
|
||||
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Model history retains up to 100 edits.
|
||||
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
|
||||
|
||||
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
|
||||
|
||||
Drag cards to move on a 10-unit grid, drag empty space to pan, and scroll to zoom. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
@@ -59,10 +61,10 @@ Connections start with orthogonal routes that avoid cards and container headers.
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
**Textual model** has syntax highlighting, live linting and explicit **Apply to model**. For example:
|
||||
**Textual model** has per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
arcadia 2;
|
||||
arcadia 3;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
@@ -75,15 +77,15 @@ project harbor "Harbor assistance" {
|
||||
}
|
||||
```
|
||||
|
||||
New declarations may omit IDs; Apply writes assigned UUIDs into canonical source. Keep existing identity clauses to preserve objects. Stable `@aliases` survive display-name changes and support forward references. The [normative specification](docs/language.rst) covers every supported declaration, typed constructor, relationship and persisted diagram field.
|
||||
New declarations may omit IDs; Apply inserts assigned UUIDs into their source. Keep existing identity clauses to preserve objects. Stable `@aliases` survive display-name changes and support forward references. The [normative specification](docs/language.rst) covers every supported declaration, typed constructor, relationship and persisted diagram field.
|
||||
|
||||
Applying valid source commits the whole candidate as one model transaction. Graphical edits regenerate canonical source. Invalid source remains a draft with located diagnostics. **Undo text** and **Undo model** are distinct histories. Apply canonicalizes formatting and removes comments; comments and whitespace are not retained model data. Names, aliases, exact strings, typed values, references, occurrence identity and placement data are retained. Version 1 source/JSON is migrated explicitly without inventing SA content.
|
||||
Applying valid source commits the reachable source bundle as one model transaction. Graphical edits update the owning declarations while preserving unaffected formatting and comments. Invalid source remains in its file buffer with located diagnostics, including while another file is open. **Undo text** and **Undo model** are distinct: model Undo/Redo restores the accepted model and its exact source documents together. JSON export carries model semantics; comments and source-file organization belong to the source project. Version 1 source/JSON is migrated explicitly without inventing SA content.
|
||||
|
||||
## Save and reopen
|
||||
|
||||
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
|
||||
Save validates a snapshot and atomically replaces the destination. Normal saves compare the previously read file bytes and reject a detected external change; preserve your draft and use **Save as** or reopen deliberately. This is a conflict check, not distributed locking. Dirty New/Open/Close operations offer save/discard/cancel. There is no autosave or crash recovery.
|
||||
**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.
|
||||
|
||||
## REST service and external tools
|
||||
|
||||
@@ -97,11 +99,11 @@ 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, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation as the desktop. Writes require the returned ETag in `If-Match`. The server retains the current revision only.
|
||||
|
||||
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Save a model as `system.arcadia`, import it, then edit using the API:
|
||||
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 current import endpoint accepts a complete JSON model or standalone canonical source; it does not resolve a desktop project's included files.
|
||||
|
||||
```sh
|
||||
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
||||
-H 'Content-Type: text/plain' --data-binary @system.arcadia
|
||||
-H 'Content-Type: application/json' --data-binary @system.json
|
||||
```
|
||||
|
||||
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
|
||||
@@ -2385,6 +2385,29 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
|
||||
}
|
||||
!issues.iter().any(|i| i.severity == Severity::Error)
|
||||
}
|
||||
|
||||
/// Feasibility for a boundary exchange with its actual orientation. This checks
|
||||
/// only the proposed relationship, so endpoint pickers need not clone and
|
||||
/// revalidate the complete model for every port. Legacy directions remain fixed
|
||||
/// even when the exchange is unoriented.
|
||||
pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool) -> bool {
|
||||
let candidate = Relation {
|
||||
id: Id::nil(),
|
||||
alias: "candidate".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented,
|
||||
items: vec![],
|
||||
},
|
||||
};
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
!issues.iter().any(|i| i.severity == Severity::Error)
|
||||
}
|
||||
/// Aggregate display information only; this never creates inherited allocations.
|
||||
pub fn allocation_summary(p: &Project, work: Id) -> Option<Id> {
|
||||
let root = p.elements.get(&work)?;
|
||||
|
||||
+514
-55
@@ -26,6 +26,20 @@ pub struct FileSnapshot {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SaveFailure {
|
||||
pub document: Id,
|
||||
pub message: String,
|
||||
pub recovery: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecoverySnapshot {
|
||||
pub target: PathBuf,
|
||||
/// Recovery already succeeded even when reloading its files fails.
|
||||
pub disk: Result<DiskProject, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Diagram,
|
||||
@@ -81,6 +95,12 @@ pub enum Message {
|
||||
StartTransition,
|
||||
Workflow(crate::workflow::Event),
|
||||
CollapseExplorer(Id),
|
||||
ExplorerGroup(crate::explorer::Group),
|
||||
ExplorerCollapseAll,
|
||||
ExplorerLocate,
|
||||
Explore(Option<Id>),
|
||||
BrowseGroup(crate::explorer::Group),
|
||||
ExplorerPage(usize),
|
||||
CreateKind(Kind),
|
||||
Add(bool),
|
||||
AddRelation,
|
||||
@@ -127,7 +147,7 @@ pub enum Message {
|
||||
SourceCloseFile(String),
|
||||
SourceLoadIncludes,
|
||||
SourceRecover,
|
||||
SourceRecovered(Result<DiskProject, String>),
|
||||
SourceRecovered(Result<RecoverySnapshot, String>),
|
||||
IncludesLoaded(u64, Result<DiskProject, String>),
|
||||
Lint(u64),
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
@@ -138,7 +158,7 @@ pub enum Message {
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
Save(bool),
|
||||
SavePath(Option<PathBuf>),
|
||||
Saved(Result<Option<FileSnapshot>, String>),
|
||||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||||
Loaded(Result<Option<LoadedSnapshot>, String>),
|
||||
Request(Pending),
|
||||
Discard,
|
||||
@@ -177,6 +197,8 @@ pub struct App {
|
||||
pub pending: Option<Pending>,
|
||||
pub delete_pending: bool,
|
||||
pub path: Option<PathBuf>,
|
||||
/// A disk transaction requiring an explicit action, independent of lint.
|
||||
pub pending_recovery: Option<PathBuf>,
|
||||
disk_project: Option<DiskProject>,
|
||||
pub file_prompt: Option<FilePrompt>,
|
||||
continue_after_save: bool,
|
||||
@@ -198,6 +220,11 @@ pub struct App {
|
||||
lint_running: Option<u64>,
|
||||
lint_requested: Option<u64>,
|
||||
pub explorer_collapsed: BTreeSet<Id>,
|
||||
pub explorer_groups_collapsed: BTreeSet<crate::explorer::Group>,
|
||||
pub explorer_index: crate::explorer::Index,
|
||||
pub explorer_focus: Option<Id>,
|
||||
pub explorer_group: Option<crate::explorer::Group>,
|
||||
pub explorer_page: usize,
|
||||
pub workflow: Option<crate::workflow::Workflow>,
|
||||
}
|
||||
|
||||
@@ -230,6 +257,8 @@ impl App {
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
let draft = Draft::from_project(&project, selected);
|
||||
let explorer_index = crate::explorer::Index::new(&project, layer);
|
||||
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
|
||||
Self {
|
||||
canvas_document: Id::new_v4(),
|
||||
canvas_epoch: 0,
|
||||
@@ -256,6 +285,7 @@ impl App {
|
||||
pending: None,
|
||||
delete_pending: false,
|
||||
path: None,
|
||||
pending_recovery: None,
|
||||
disk_project: None,
|
||||
file_prompt: None,
|
||||
continue_after_save: false,
|
||||
@@ -276,7 +306,12 @@ impl App {
|
||||
source_version: 0,
|
||||
lint_running: None,
|
||||
lint_requested: None,
|
||||
explorer_collapsed: BTreeSet::new(),
|
||||
explorer_collapsed,
|
||||
explorer_groups_collapsed: BTreeSet::new(),
|
||||
explorer_index,
|
||||
explorer_focus: None,
|
||||
explorer_group: None,
|
||||
explorer_page: 0,
|
||||
workflow: None,
|
||||
}
|
||||
}
|
||||
@@ -343,12 +378,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn sync(&mut self) {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.model_issues = validate(self.project());
|
||||
self.normalize_creation();
|
||||
if !self.project().contains(self.selected) {
|
||||
self.selected = self.project().id;
|
||||
self.selected_node = None;
|
||||
}
|
||||
if let Some(focus) = self.explorer_focus {
|
||||
self.explorer_group = self.explorer_index.group(focus);
|
||||
if self.explorer_group.is_none() {
|
||||
self.explorer_focus = None;
|
||||
}
|
||||
}
|
||||
self.reveal_explorer_selection();
|
||||
if self.active_diagram.is_none_or(|id| {
|
||||
self.project()
|
||||
.diagrams
|
||||
@@ -379,6 +422,10 @@ impl App {
|
||||
if !self.source_dirty() {
|
||||
return true;
|
||||
}
|
||||
if self.draft_dirty() {
|
||||
self.status = "Property and source drafts both exist. Revert one draft before applying the other; both are retained.".into();
|
||||
return false;
|
||||
}
|
||||
match source_workspace::compile(&self.source_workspace.bundle()) {
|
||||
Ok(compiled) => {
|
||||
match self.document.apply_compiled(compiled) {
|
||||
@@ -513,6 +560,22 @@ impl App {
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
fn reveal_explorer_selection(&mut self) {
|
||||
if let Some(group) = self.explorer_index.group(self.selected) {
|
||||
self.explorer_groups_collapsed.remove(&group);
|
||||
for id in self.explorer_index.ancestry(self.selected) {
|
||||
if id != self.selected {
|
||||
self.explorer_collapsed.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self
|
||||
.explorer_focus
|
||||
.is_some_and(|id| !self.project().elements.contains_key(&id))
|
||||
{
|
||||
self.explorer_focus = None;
|
||||
}
|
||||
}
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
crate::views::view(self)
|
||||
}
|
||||
@@ -561,6 +624,22 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
if self.source_dirty()
|
||||
&& matches!(
|
||||
message,
|
||||
Message::Field(..)
|
||||
| Message::FieldText(..)
|
||||
| Message::FieldShape(..)
|
||||
| Message::FieldVariant(..)
|
||||
| Message::FieldArray(..)
|
||||
| Message::ElementKind(_)
|
||||
| Message::RelationKind(_)
|
||||
)
|
||||
{
|
||||
self.status =
|
||||
"Apply all source drafts or revert them before editing properties.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.busy
|
||||
&& !matches!(
|
||||
message,
|
||||
@@ -640,9 +719,35 @@ impl App {
|
||||
self.selected_bend = None;
|
||||
}
|
||||
let previous = self.selected;
|
||||
let task = self.handle(message);
|
||||
let previous_layer = self.layer;
|
||||
let entering_catalog =
|
||||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||||
let mut task = self.handle(message);
|
||||
if previous_layer != self.layer {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
self.explorer_group = None;
|
||||
self.explorer_page = 0;
|
||||
task = task.chain(Task::batch(["model-explorer", "model-browser"].map(|id| {
|
||||
iced::widget::operation::snap_to(
|
||||
id,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
)
|
||||
})));
|
||||
}
|
||||
self.normalize_creation();
|
||||
if self.page == Page::Catalog && (self.selected != previous || entering_catalog) {
|
||||
self.explorer_focus = self
|
||||
.project()
|
||||
.elements
|
||||
.get(&self.selected)
|
||||
.filter(|element| element.layer() == self.layer)
|
||||
.map(|element| element.id);
|
||||
self.explorer_group = self.explorer_index.group(self.selected);
|
||||
self.explorer_page = 0;
|
||||
}
|
||||
if self.selected != previous {
|
||||
self.reveal_explorer_selection();
|
||||
task.chain(iced::widget::operation::snap_to(
|
||||
"inspector",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
@@ -748,7 +853,16 @@ impl App {
|
||||
self.refresh_draft();
|
||||
}
|
||||
}
|
||||
Message::Search(value) => self.search = value,
|
||||
Message::Search(value) => {
|
||||
self.search = value;
|
||||
self.explorer_page = 0;
|
||||
return Task::batch(["model-browser", "model-explorer"].map(|id| {
|
||||
iced::widget::operation::snap_to(
|
||||
id,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
)
|
||||
}));
|
||||
}
|
||||
Message::Page(page) => {
|
||||
if page != Page::Text || !self.draft_dirty() || self.apply() {
|
||||
self.page = page;
|
||||
@@ -762,6 +876,65 @@ impl App {
|
||||
self.explorer_collapsed.insert(id);
|
||||
}
|
||||
}
|
||||
Message::ExplorerGroup(group) => {
|
||||
if !self.explorer_groups_collapsed.remove(&group) {
|
||||
self.explorer_groups_collapsed.insert(group);
|
||||
}
|
||||
}
|
||||
Message::ExplorerCollapseAll => {
|
||||
self.explorer_collapsed = self
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| e.parent)
|
||||
.collect();
|
||||
self.explorer_groups_collapsed = crate::explorer::Group::ALL.into_iter().collect();
|
||||
self.search.clear();
|
||||
return iced::widget::operation::snap_to(
|
||||
"model-explorer",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
Message::ExplorerLocate => {
|
||||
self.search.clear();
|
||||
self.reveal_explorer_selection();
|
||||
let offset = crate::explorer_view::selection_offset(self);
|
||||
return iced::widget::operation::scroll_to("model-explorer", offset);
|
||||
}
|
||||
Message::Explore(id) => {
|
||||
if self.apply() {
|
||||
self.explorer_focus = id.filter(|id| self.project().elements.contains_key(id));
|
||||
self.explorer_group = self
|
||||
.explorer_focus
|
||||
.and_then(|id| self.explorer_index.group(id));
|
||||
self.search.clear();
|
||||
self.explorer_page = 0;
|
||||
self.page = Page::Catalog;
|
||||
self.select(self.explorer_focus.unwrap_or(self.project().id));
|
||||
return iced::widget::operation::snap_to(
|
||||
"model-browser",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::BrowseGroup(group) => {
|
||||
self.explorer_focus = None;
|
||||
self.explorer_group = Some(group);
|
||||
self.search.clear();
|
||||
self.explorer_page = 0;
|
||||
self.page = Page::Catalog;
|
||||
return iced::widget::operation::snap_to(
|
||||
"model-browser",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
Message::ExplorerPage(page) => {
|
||||
self.explorer_page = page;
|
||||
return iced::widget::operation::snap_to(
|
||||
"model-browser",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
Message::StartTransition => {
|
||||
if self.apply() {
|
||||
match crate::workflow::Workflow::transition(self.project(), self.selected) {
|
||||
@@ -776,6 +949,8 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(mut workflow) = self.workflow.take() {
|
||||
let stay_in_catalog = self.page == Page::Catalog
|
||||
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
|
||||
let transition_focus = match &workflow {
|
||||
crate::workflow::Workflow::Ports(state) => Some(state.focus()),
|
||||
crate::workflow::Workflow::Transition(t) => {
|
||||
@@ -818,8 +993,10 @@ impl App {
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
self.page = Page::Diagram;
|
||||
self.fit();
|
||||
if !stay_in_catalog {
|
||||
self.page = Page::Diagram;
|
||||
self.fit();
|
||||
}
|
||||
self.status="Reviewed change applied · Undo restores the complete operation".into();
|
||||
} else {
|
||||
self.workflow = Some(workflow);
|
||||
@@ -914,6 +1091,7 @@ impl App {
|
||||
if self.apply() {
|
||||
match crate::port_workflow::State::new(self.project(), self.selected, action) {
|
||||
Ok(state) => {
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.workflow = Some(crate::workflow::Workflow::Ports(Box::new(state)))
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
@@ -1277,17 +1455,13 @@ impl App {
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(path) = self.path.clone() {
|
||||
if let Some(path) = self.pending_recovery.clone() {
|
||||
self.busy = true;
|
||||
return Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
source_files::recover_project(&path, LIMITS)
|
||||
.map_err(|e| e.to_string())?;
|
||||
source_files::load_project(&path, LIMITS).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
tokio::task::spawn_blocking(move || recover_snapshot(path))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
},
|
||||
Message::SourceRecovered,
|
||||
);
|
||||
@@ -1296,12 +1470,34 @@ impl App {
|
||||
Message::SourceRecovered(result) => {
|
||||
self.busy = false;
|
||||
match result {
|
||||
Ok(disk) if !self.source_workspace.applied => self.open_source_project(disk),
|
||||
Ok(disk) => {
|
||||
self.disk_project = Some(disk);
|
||||
Ok(receipt) => {
|
||||
if self.pending_recovery.as_ref() != Some(&receipt.target) {
|
||||
return Task::none();
|
||||
}
|
||||
self.pending_recovery = None;
|
||||
self.source_diagnostics
|
||||
.retain(|issue| issue.diagnostic.code != "pending_save");
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
match receipt.disk {
|
||||
Ok(disk) if !self.source_workspace.applied => {
|
||||
self.open_source_project(disk)
|
||||
}
|
||||
Ok(disk) => {
|
||||
// Recovering an old Save As target must not redirect
|
||||
// the source project that was subsequently saved elsewhere.
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.path = Some(receipt.target);
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!(
|
||||
"Disk recovery completed; files could not be reloaded: {error}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
@@ -1311,6 +1507,9 @@ impl App {
|
||||
if version == self.source_version {
|
||||
match result {
|
||||
Ok(disk) => {
|
||||
if let Some(target) = recovery_target(&disk) {
|
||||
self.pending_recovery = Some(target);
|
||||
}
|
||||
self.source_workspace
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.source_version += 1;
|
||||
@@ -1335,15 +1534,7 @@ impl App {
|
||||
self.lint_running = None;
|
||||
if version == self.source_version {
|
||||
match result {
|
||||
Ok(mut diagnostics) => {
|
||||
diagnostics.extend(
|
||||
self.source_diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.diagnostic.code == "pending_save")
|
||||
.cloned(),
|
||||
);
|
||||
self.source_diagnostics = diagnostics;
|
||||
}
|
||||
Ok(diagnostics) => self.source_diagnostics = diagnostics,
|
||||
Err(error) => self.status = format!("Source checks failed: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -1491,6 +1682,11 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(disk) = receipt.disk {
|
||||
if self.pending_recovery.as_ref()
|
||||
== Some(&disk.root.join(&disk.bundle.entry))
|
||||
{
|
||||
self.pending_recovery = None;
|
||||
}
|
||||
if receipt.snapshot.bundle.entry != self.document.bundle().entry {
|
||||
if receipt.revision != self.document.revision() {
|
||||
self.status = format!(
|
||||
@@ -1533,7 +1729,16 @@ impl App {
|
||||
self.continue_after_save = false;
|
||||
}
|
||||
Err(e) => {
|
||||
self.status = e;
|
||||
if e.document != self.canvas_document {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(target) = e.recovery {
|
||||
self.pending_recovery = Some(target);
|
||||
}
|
||||
self.status = e.message;
|
||||
if self.pending_recovery.is_some() {
|
||||
self.page = Page::Text;
|
||||
}
|
||||
self.continue_after_save = false;
|
||||
}
|
||||
}
|
||||
@@ -1920,13 +2125,14 @@ impl App {
|
||||
let document = self.canvas_document;
|
||||
let revision = self.document.revision();
|
||||
let previous = self.disk_project.clone();
|
||||
let failed_target = path.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
write_snapshot(document, revision, snapshot, path, previous).map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| save_failure(document, &failed_target, e.to_string()))?
|
||||
},
|
||||
Message::Saved,
|
||||
)
|
||||
@@ -1956,6 +2162,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn open_source_project(&mut self, disk: DiskProject) {
|
||||
let pending_recovery = recovery_target(&disk);
|
||||
let path = disk.root.join(&disk.bundle.entry);
|
||||
let candidate = if !disk
|
||||
.diagnostics
|
||||
@@ -1995,6 +2202,7 @@ impl App {
|
||||
self.status = "Source needs repair. Loaded drafts are retained; the graphical model is unchanged.".into();
|
||||
}
|
||||
}
|
||||
self.pending_recovery = pending_recovery;
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
|
||||
@@ -2367,6 +2575,52 @@ fn reveal_port_owner(p: &mut Project, diagram: Id, element: Id) {
|
||||
}
|
||||
|
||||
fn write_snapshot(
|
||||
document: Id,
|
||||
revision: u64,
|
||||
snapshot: Arc<CompiledSourceProject>,
|
||||
path: PathBuf,
|
||||
previous: Option<DiskProject>,
|
||||
) -> Result<FileSnapshot, SaveFailure> {
|
||||
let target = path.clone();
|
||||
write_snapshot_inner(document, revision, snapshot, path, previous)
|
||||
.map_err(|message| save_failure(document, &target, message))
|
||||
}
|
||||
|
||||
fn save_failure(document: Id, path: &std::path::Path, message: String) -> SaveFailure {
|
||||
// Discovery is read-only, bounded and checks physical source ownership. Do
|
||||
// not infer journal names from an error string or recover inside a save.
|
||||
let recovery = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
.then(|| source_files::load_project(path, LIMITS).ok())
|
||||
.flatten()
|
||||
.and_then(|disk| recovery_target(&disk));
|
||||
SaveFailure {
|
||||
document,
|
||||
message,
|
||||
recovery,
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_target(disk: &DiskProject) -> Option<PathBuf> {
|
||||
disk.diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.code == "pending_save")
|
||||
.then(|| disk.root.join(&disk.bundle.entry))
|
||||
}
|
||||
|
||||
fn recover_snapshot(target: PathBuf) -> Result<RecoverySnapshot, String> {
|
||||
if !source_files::recover_project(&target, LIMITS).map_err(|e| e.to_string())? {
|
||||
return Err(format!(
|
||||
"No pending save was found at {}. Your model and drafts are retained.",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
let disk = source_files::load_project(&target, LIMITS).map_err(|e| e.to_string());
|
||||
Ok(RecoverySnapshot { target, disk })
|
||||
}
|
||||
|
||||
fn write_snapshot_inner(
|
||||
document: Id,
|
||||
revision: u64,
|
||||
mut snapshot: Arc<CompiledSourceProject>,
|
||||
@@ -2442,15 +2696,33 @@ fn write_snapshot(
|
||||
|
||||
fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
use std::io::Read;
|
||||
let is_source = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
|
||||
if is_source {
|
||||
// The entry is written last. An interrupted first save can therefore
|
||||
// have a valid journal without an entry file to open yet.
|
||||
let disk = source_files::load_project(&path, LIMITS).map_err(|e| e.to_string())?;
|
||||
if recovery_target(&disk).is_none()
|
||||
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
|
||||
disk.expectations.get(&disk.bundle.entry)
|
||||
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
|
||||
&& decoded.migration.is_some()
|
||||
{
|
||||
return Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
Box::new(decoded),
|
||||
Arc::from(bytes.clone()),
|
||||
));
|
||||
}
|
||||
return Ok(LoadedSnapshot::Source(disk));
|
||||
}
|
||||
if !std::fs::symlink_metadata(&path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("Open a regular model file; links and special files are not supported.".into());
|
||||
}
|
||||
let is_source = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(&path)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -2460,27 +2732,13 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
if bytes.len() > LIMITS.model.max_source_bytes {
|
||||
return Err("The model exceeds the source byte limit.".into());
|
||||
}
|
||||
if !is_source {
|
||||
let decoded = persistence::decode_with_report_limited(&bytes, LIMITS.model)
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
Box::new(decoded),
|
||||
Arc::from(bytes),
|
||||
));
|
||||
}
|
||||
if let Ok(decoded) = persistence::decode_with_report_limited(&bytes, LIMITS.model)
|
||||
&& decoded.migration.is_some()
|
||||
{
|
||||
return Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
Box::new(decoded),
|
||||
Arc::from(bytes),
|
||||
));
|
||||
}
|
||||
source_files::load_project(&path, LIMITS)
|
||||
.map(LoadedSnapshot::Source)
|
||||
.map_err(|e| e.to_string())
|
||||
let decoded =
|
||||
persistence::decode_with_report_limited(&bytes, LIMITS.model).map_err(|e| e.to_string())?;
|
||||
Ok(LoadedSnapshot::Legacy(
|
||||
path,
|
||||
Box::new(decoded),
|
||||
Arc::from(bytes),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2528,10 +2786,173 @@ mod source_project_tests {
|
||||
app.document.snapshot(),
|
||||
path,
|
||||
app.disk_project.clone(),
|
||||
)?;
|
||||
)
|
||||
.map_err(|error| error.message)?;
|
||||
send(app, Message::Saved(Ok(Some(receipt))));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
let metadata = json!({"version":1,"entry":"project.arcadia","files":[{
|
||||
"path":"project.arcadia","before":before.is_some(),"write":true
|
||||
}]});
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
serde_json::to_vec(&metadata).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(before) = before {
|
||||
std::fs::write(&path, before).unwrap();
|
||||
std::fs::write(journal.join("0.before"), before).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
journal.join("0.after"),
|
||||
"arcadia 3; project p \"After\" {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
(path, journal)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_open_finds_first_save_journal_without_an_entry_file() {
|
||||
let directory = Directory::new();
|
||||
let (path, journal) = pending_journal(&directory, None);
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let before = app.document.snapshot();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert_eq!(app.page, Page::Text);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(journal.exists());
|
||||
send(&mut app, Message::SourceRecover);
|
||||
assert!(app.busy);
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||||
);
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert!(!path.exists());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_after_first_save_failure_retains_target_then_retries_without_reentering_path() {
|
||||
let directory = Directory::new();
|
||||
let (path, journal) = pending_journal(&directory, None);
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let before = app.document.snapshot();
|
||||
let error = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.recovery.as_ref(), Some(&path));
|
||||
send(&mut app, Message::Saved(Err(error)));
|
||||
assert!(app.path.is_none());
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert_eq!(app.page, Page::Text);
|
||||
send(&mut app, Message::SourceRecover);
|
||||
assert!(
|
||||
app.busy,
|
||||
"Recovery must use its own target when App.path is absent"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||||
);
|
||||
assert_eq!(app.path.as_ref(), Some(&path));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
save(&mut app, path.clone()).unwrap();
|
||||
assert!(path.is_file());
|
||||
assert!(!app.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_target_survives_edit_lint_apply_and_rejected_recovery_until_success() {
|
||||
let directory = Directory::new();
|
||||
let initial = "arcadia 3; project p \"Before\" {}\n";
|
||||
let (path, journal) = pending_journal(&directory, Some(initial));
|
||||
let mut app = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
);
|
||||
edit(&mut app, &format!("{initial}// Added rationale\n"));
|
||||
assert!(app.source_diagnostics.is_empty());
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
let version = app.source_version;
|
||||
send(&mut app, Message::Lint(version));
|
||||
let diagnostics = source_workspace::lint(&app.source_workspace.bundle());
|
||||
send(&mut app, Message::Linted(version, Ok(diagnostics)));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(app.apply_source());
|
||||
let accepted = app.document.snapshot();
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
std::fs::write(&path, "External file edit that must survive").unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||||
);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(journal.exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"External file edit that must survive"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
std::fs::write(&path, initial).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||||
);
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(app.source_editor.text().contains("Added rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_of_previous_save_as_target_does_not_redirect_current_project() {
|
||||
let directory = Directory::new();
|
||||
let (target, journal) = pending_journal(&directory, None);
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let error = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
target.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
send(&mut app, Message::Saved(Err(error)));
|
||||
let destination = directory.0.join("another.arcadia");
|
||||
save(&mut app, destination.clone()).unwrap();
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&target));
|
||||
let saved = app.document.snapshot();
|
||||
send(&mut app, Message::SourceRecovered(recover_snapshot(target)));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(app.path.as_ref(), Some(&destination));
|
||||
assert_eq!(
|
||||
app.disk_project.as_ref().unwrap().bundle.entry,
|
||||
"another.arcadia"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&saved, &app.document.snapshot()));
|
||||
save(&mut app, destination).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn two_file_apply_graphical_edit_exact_undo_save_as_and_reopen() {
|
||||
let directory = Directory::new();
|
||||
@@ -2750,6 +3171,39 @@ mod source_project_tests {
|
||||
mod port_view_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_creation_follows_the_new_port_and_keeps_group_browsing_explicit() {
|
||||
let mut project = Project::empty();
|
||||
let actor = project
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "External operator", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Explore(Some(actor)));
|
||||
app.explorer_page = 3;
|
||||
app.inspector_tab = crate::navigation::InspectorTab::Connections;
|
||||
let _ = app.update(Message::PortAction(crate::port_workflow::Action::Component));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert_eq!(app.page, Page::Catalog);
|
||||
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
|
||||
assert_eq!(app.explorer_focus, Some(app.selected));
|
||||
assert_eq!(app.explorer_group, app.explorer_index.group(app.selected));
|
||||
assert_eq!(app.explorer_page, 0);
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Properties
|
||||
);
|
||||
let selected = app.selected;
|
||||
let group = app.explorer_group.unwrap();
|
||||
let _ = app.update(Message::BrowseGroup(group));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.explorer_group, Some(group));
|
||||
assert!(app.explorer_focus.is_none());
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(app.explorer_focus, Some(selected));
|
||||
assert_eq!(app.explorer_page, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_active_view_cannot_hide_a_created_port_when_eligible_owner_view_is_folded() {
|
||||
let mut p = Project::empty();
|
||||
@@ -2791,9 +3245,14 @@ mod port_view_tests {
|
||||
let _ = app.update(Message::Select(function));
|
||||
app.active_diagram = Some(capabilities);
|
||||
app.refresh_draft();
|
||||
app.inspector_tab = crate::navigation::InspectorTab::Connections;
|
||||
let _ = app.update(Message::PortAction(crate::port_workflow::Action::Output));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Properties
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert!(
|
||||
project_view(app.project(), architecture)
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Containment navigation: a compact outline and a focused, paged model browser.
|
||||
use crate::{
|
||||
app::{App, Message, Page},
|
||||
explorer::Group,
|
||||
style,
|
||||
views::{action, heading, label, short},
|
||||
};
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{self, Space, button, column, container, row, scrollable, text, tooltip},
|
||||
};
|
||||
use oa_core::{Id, Kind, RelationKind};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const ROW_HEIGHT: f32 = 32.;
|
||||
const GROUP_HEIGHT: f32 = 36.;
|
||||
const PAGE_SIZE: usize = 100;
|
||||
|
||||
pub fn sidebar(app: &App) -> View<'_, Message> {
|
||||
let mut content = column![].spacing(0);
|
||||
let mut found = false;
|
||||
let query = app.search.trim();
|
||||
for group in Group::ALL {
|
||||
let rows = app
|
||||
.explorer_index
|
||||
.rows(group, query, &app.explorer_collapsed);
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
let expanded = !query.is_empty() || !app.explorer_groups_collapsed.contains(&group);
|
||||
content = content.push(
|
||||
button(
|
||||
row![
|
||||
text(if expanded { "⌄" } else { "›" }).size(16).width(18),
|
||||
text(group.label(app.layer)).size(12).width(Fill),
|
||||
label(app.explorer_index.roots[&group].len().to_string()),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.height(GROUP_HEIGHT)
|
||||
.padding([7, 4])
|
||||
// The horizontal scrollable has unbounded width: a finite header
|
||||
// keeps an outline containing only collapsed groups visible.
|
||||
.width(226)
|
||||
.style(style::nav)
|
||||
.on_press_maybe(query.is_empty().then_some(Message::ExplorerGroup(group))),
|
||||
);
|
||||
if !expanded {
|
||||
continue;
|
||||
}
|
||||
for entry in rows {
|
||||
let element = &app.project().elements[&entry.id];
|
||||
let mut outline = row![].height(ROW_HEIGHT).align_y(iced::Center);
|
||||
// Keep a constant number of widgets per row, including deep search
|
||||
// paths. The full indentation remains horizontally scrollable.
|
||||
outline = outline.push(Space::new().width(entry.depth as f32 * 16.));
|
||||
outline = outline.push(row![
|
||||
Space::new().width(8),
|
||||
container(Space::new())
|
||||
.width(1)
|
||||
.height(ROW_HEIGHT)
|
||||
.style(|_| container::Style {
|
||||
background: Some(style::LINE.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
Space::new().width(7)
|
||||
]);
|
||||
outline = outline.push(
|
||||
button(
|
||||
text(if entry.has_children {
|
||||
if query.is_empty() && app.explorer_collapsed.contains(&entry.id) {
|
||||
"›"
|
||||
} else {
|
||||
"⌄"
|
||||
}
|
||||
} else {
|
||||
"·"
|
||||
})
|
||||
.size(15),
|
||||
)
|
||||
.style(style::nav)
|
||||
.width(24)
|
||||
.height(ROW_HEIGHT)
|
||||
.padding(4)
|
||||
.on_press_maybe(
|
||||
(entry.has_children && query.is_empty())
|
||||
.then_some(Message::CollapseExplorer(entry.id)),
|
||||
),
|
||||
);
|
||||
let caption = row![
|
||||
container(Space::new())
|
||||
.width(3)
|
||||
.height(15)
|
||||
.style(move |_| container::Style {
|
||||
background: Some(style::kind_color(element.kind()).into()),
|
||||
..Default::default()
|
||||
}),
|
||||
text(short(&element.name, 25 - entry.depth.min(2) * 3))
|
||||
.size(12)
|
||||
.wrapping(text::Wrapping::None)
|
||||
.color(if entry.matched {
|
||||
style::INK
|
||||
} else {
|
||||
style::MUTED
|
||||
})
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center);
|
||||
let select = if app.page == Page::Catalog {
|
||||
Message::Explore(Some(entry.id))
|
||||
} else {
|
||||
Message::Select(entry.id)
|
||||
};
|
||||
let node = button(caption)
|
||||
.width(186. - entry.depth.min(2) as f32 * 16.)
|
||||
.height(ROW_HEIGHT)
|
||||
.padding([7, 6])
|
||||
.clip(true)
|
||||
.on_press(select)
|
||||
.style(if app.selected == entry.id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
});
|
||||
outline = outline.push(tooltip(
|
||||
node,
|
||||
container(
|
||||
column![
|
||||
text(&element.name).size(13),
|
||||
label(format!("{} · @{}", element.kind(), element.alias))
|
||||
]
|
||||
.spacing(4),
|
||||
)
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
tooltip::Position::Right,
|
||||
));
|
||||
content = content.push(outline);
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
content = content.push(label(if query.is_empty() {
|
||||
"No elements yet. Create one below."
|
||||
} else {
|
||||
"No matches in this perspective."
|
||||
}));
|
||||
}
|
||||
scrollable(content)
|
||||
.id("model-explorer")
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn selection_offset(app: &App) -> widget::operation::AbsoluteOffset {
|
||||
let mut y = 0.;
|
||||
for group in Group::ALL {
|
||||
let rows = app.explorer_index.rows(group, "", &app.explorer_collapsed);
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
y += GROUP_HEIGHT;
|
||||
if app.explorer_groups_collapsed.contains(&group) {
|
||||
continue;
|
||||
}
|
||||
for entry in rows {
|
||||
if entry.id == app.selected {
|
||||
return widget::operation::AbsoluteOffset {
|
||||
x: entry.depth.saturating_sub(1) as f32 * 16.,
|
||||
y: (y - ROW_HEIGHT).max(0.),
|
||||
};
|
||||
}
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
widget::operation::AbsoluteOffset::default()
|
||||
}
|
||||
|
||||
fn item<'a>(app: &'a App, id: Id, context: bool) -> View<'a, Message> {
|
||||
let p = app.project();
|
||||
let e = &p.elements[&id];
|
||||
let count = app.explorer_index.children(id).len();
|
||||
let mut info = column![
|
||||
text(&e.name).size(14),
|
||||
label(format!("{} · @{}", e.kind(), e.alias))
|
||||
]
|
||||
.spacing(4);
|
||||
if context {
|
||||
let ancestors = app.explorer_index.ancestry(id);
|
||||
let path = ancestors
|
||||
.iter()
|
||||
.take(ancestors.len().saturating_sub(1))
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
if !path.is_empty() {
|
||||
info = info.push(label(path));
|
||||
}
|
||||
}
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn catalog(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let index = &app.explorer_index;
|
||||
let focus = app
|
||||
.explorer_focus
|
||||
.filter(|id| p.elements.get(id).is_some_and(|e| e.layer() == app.layer));
|
||||
let query = app.search.trim();
|
||||
let mut out = column![heading(
|
||||
"Model explorer",
|
||||
"Browse containment · search names, kinds or @aliases"
|
||||
)]
|
||||
.spacing(14);
|
||||
let mut crumbs = row![action("Project", Message::Explore(None))]
|
||||
.spacing(3)
|
||||
.align_y(iced::Center);
|
||||
if let Some(group) = app.explorer_group {
|
||||
crumbs = crumbs
|
||||
.push(text("/ ").size(12))
|
||||
.push(action(group.label(app.layer), Message::BrowseGroup(group)));
|
||||
}
|
||||
if let Some(id) = focus {
|
||||
for ancestor in index.ancestry(id) {
|
||||
crumbs = crumbs.push(text("/ ").size(12)).push(action(
|
||||
&short(p.named(ancestor), 24),
|
||||
Message::Explore(Some(ancestor)),
|
||||
));
|
||||
}
|
||||
}
|
||||
out = out.push(
|
||||
scrollable(crumbs).direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::default(),
|
||||
)),
|
||||
);
|
||||
let ids: Vec<Id>;
|
||||
if !query.is_empty() {
|
||||
ids = Group::ALL
|
||||
.into_iter()
|
||||
.flat_map(|g| index.rows(g, query, &BTreeSet::new()))
|
||||
.filter(|r| r.matched)
|
||||
.map(|r| r.id)
|
||||
.collect();
|
||||
out = out.push(label(format!(
|
||||
"{} results across this perspective",
|
||||
ids.len()
|
||||
)));
|
||||
} else if let Some(id) = focus {
|
||||
let e = &p.elements[&id];
|
||||
out = out
|
||||
.push(text(&e.name).size(21))
|
||||
.push(label(format!("{} · @{}", e.kind(), e.alias)));
|
||||
if !e.description.is_empty() {
|
||||
out = out.push(text(short(&e.description, 400)).size(13));
|
||||
}
|
||||
if matches!(e.kind(), Kind::System | Kind::SystemActor) {
|
||||
out = out.push(
|
||||
button(text("+ Add port…").size(13))
|
||||
.padding(10)
|
||||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||||
);
|
||||
out = out.push(label(
|
||||
"Owned ports appear below. Select a port to connect a boundary exchange.",
|
||||
));
|
||||
}
|
||||
ids = index.children(id).to_vec();
|
||||
out = out.push(text(format!("Contained elements · {}", ids.len())).size(15));
|
||||
} else if let Some(group) = app.explorer_group {
|
||||
ids = index.roots[&group].clone();
|
||||
out = out.push(text(group.label(app.layer)).size(19));
|
||||
} else {
|
||||
for group in Group::ALL {
|
||||
let roots = index.roots[&group].len();
|
||||
if roots == 0 {
|
||||
continue;
|
||||
}
|
||||
let total = index.rows(group, "", &BTreeSet::new()).len();
|
||||
out = out.push(
|
||||
button(
|
||||
row![
|
||||
column![
|
||||
text(group.label(app.layer)).size(17),
|
||||
label(format!("{roots} roots · {total} elements"))
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill),
|
||||
text("›").size(24)
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding(18)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press(Message::BrowseGroup(group)),
|
||||
);
|
||||
}
|
||||
if p.elements.values().all(|e| e.layer() != app.layer) {
|
||||
out = out.push(label(
|
||||
"This perspective is empty. Create the first model element in the sidebar.",
|
||||
));
|
||||
}
|
||||
ids = vec![];
|
||||
}
|
||||
let page = app
|
||||
.explorer_page
|
||||
.min(ids.len().saturating_sub(1) / PAGE_SIZE);
|
||||
if ids.len() > PAGE_SIZE {
|
||||
out = out.push(page_controls(page, ids.len()));
|
||||
}
|
||||
for id in ids.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
out = out.push(item(app, *id, !query.is_empty()));
|
||||
}
|
||||
if ids.len() > PAGE_SIZE {
|
||||
out = out.push(page_controls(page, ids.len()));
|
||||
}
|
||||
if query.is_empty()
|
||||
&& let Some(id) = focus
|
||||
{
|
||||
if ids.is_empty() {
|
||||
out = out.push(label("No contained elements. Properties are on the right."));
|
||||
}
|
||||
// Allocation is deliberately separate from containment.
|
||||
let allocations: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation && r.source() == id)
|
||||
.collect();
|
||||
if !allocations.is_empty() {
|
||||
out = out.push(text(format!("Allocated work · {}", allocations.len())).size(15));
|
||||
for r in allocations {
|
||||
out = out.push(
|
||||
action(p.named(r.target()), Message::Explore(Some(r.target()))).width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.push(action(
|
||||
"Connections and references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
));
|
||||
}
|
||||
scrollable(out.padding(22)).id("model-browser").into()
|
||||
}
|
||||
|
||||
fn page_controls(page: usize, total: usize) -> View<'static, Message> {
|
||||
row![
|
||||
button(text("Previous").size(12))
|
||||
.padding(8)
|
||||
.on_press_maybe((page > 0).then_some(Message::ExplorerPage(page.saturating_sub(1)))),
|
||||
label(format!(
|
||||
"{}–{} of {total}",
|
||||
page * PAGE_SIZE + 1,
|
||||
((page + 1) * PAGE_SIZE).min(total)
|
||||
)),
|
||||
button(text("Next").size(12)).padding(8).on_press_maybe(
|
||||
((page + 1) * PAGE_SIZE < total).then_some(Message::ExplorerPage(page + 1))
|
||||
)
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
@@ -270,6 +270,14 @@ fn article(topic: Topic) -> Article {
|
||||
"Three things to distinguish",
|
||||
"Semantic parent records ownership: an activity contains subactivities, or a scenario owns occurrences. Allocation records who performs leaf work. A placement records where one element appears in a diagram. One element can have placements in several views without becoming several semantic elements.",
|
||||
),
|
||||
walkthrough(
|
||||
"Browse a large model",
|
||||
&[
|
||||
"Open Model explorer. Choose a group, then a container to see its direct children. Breadcrumbs return to its owner or group; Allocated work is a separate list of responsibilities.",
|
||||
"Search names, kinds or @aliases in the left outline. Matching elements retain their ancestor context; the main browser lists up to 100 results per page. Select a result to open its details and clear the search.",
|
||||
"Use Collapse all to fold the outline and Locate to reveal the selected element. Search keeps matching ancestry open; clear the search before folding individual groups or containers.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Choose and edit the relationship you mean",
|
||||
&[
|
||||
@@ -508,9 +516,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Connect participant boundaries",
|
||||
&[
|
||||
"Create an Interface that names the contract, then ports under the participants or work that owns them. Set each port's direction and compatible contract references.",
|
||||
"In System Analysis, select a System or System Actor and choose Add port in Model explorer or Properties. Name it, choose Standard or Flow, then Create port. Standard ports describe interfaces; Flow ports have their own input/output orientation. Functions use separate input/output port actions.",
|
||||
"Use Interface provision and Interface requirement relationships to state who provides or needs the contract.",
|
||||
"Create a Boundary exchange from an output-capable port to an input-capable port. Connect carried functional exchanges using Exchange allocation.",
|
||||
"Select the component port and choose Connect boundary exchange. Choose the other participant's compatible port; owner names and aliases distinguish repeated port names. Create the matching port first if none exists. Exchange orientation is separate from Flow port orientation. Connect carried functional exchanges using Exchange allocation.",
|
||||
"Link the detailed interface-control document, CAD connector or schematic through an Artifact reference when an external artifact supplies additional evidence.",
|
||||
],
|
||||
),
|
||||
@@ -599,18 +607,18 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Source => {
|
||||
const {
|
||||
Article {
|
||||
summary: "The text editor and graphical forms edit the same model. Canonical Arcadia source is intended for human review, automation and version control.",
|
||||
summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
&[
|
||||
"Open Textual model for the full canonical source, or choose Source in Properties to locate the selected record.",
|
||||
"Open Textual model and choose a source file, or choose Source in Properties to locate the selected record in its owning file. Switching files preserves each draft.",
|
||||
"Use Find / go to, Ctrl+F or Ctrl+G to navigate. Literal search is case sensitive. Enter/F3 selects the next match, Shift+F3 selects the previous match, and results wrap. Go to line accepts the displayed one-based line number.",
|
||||
"Edit the draft. Syntax highlighting distinguishes source structure; live lint reports errors with line and column information.",
|
||||
"Choose Apply to model to parse and validate the complete candidate. A successful application is one model change; graphical views and Properties then reflect it.",
|
||||
"Choose Apply all to parse and validate all reachable included files. A successful application is one model change; graphical views and Properties then reflect it. Finish or revert source drafts before editing graphical properties.",
|
||||
"If parsing or validation fails, the accepted model remains unchanged and the source draft is preserved. Correct the reported location or use Revert draft to restore the accepted source.",
|
||||
"Save as .arcadia for canonical UTF-8 text. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
|
||||
"Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -624,7 +632,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Use one contract",
|
||||
"The normative specification is docs/language.rst in the distribution. It defines the source grammar and supported graphical semantics together. Text can preserve semantic records, diagram placements, ordered occurrences, traces and artifact references; it is not merely an export of visible boxes. Applying source normalizes formatting and removes comments; put durable engineering rationale in model descriptions.",
|
||||
"The normative specification is docs/language.rst in the distribution. It defines source grammar and supported graphical semantics together. Text preserves semantic records, diagram placements, ordered occurrences, traces and artifact references. Graphical edits preserve unaffected source formatting and comments; accepted model Undo/Redo also restores the exact source files. Comments and source-file organization are not part of JSON export.",
|
||||
),
|
||||
explain(
|
||||
"Resolve source-control conflicts before applying",
|
||||
@@ -667,6 +675,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Undo and recover deliberately",
|
||||
"Ctrl+Z and redo restore accepted model changes. While the source editor is active, they operate on the source draft instead. Revert restores the accepted source or properties. If a file changed externally, save refuses a blind overwrite: preserve your work with Save as, then compare the files.",
|
||||
),
|
||||
explain(
|
||||
"Recover an interrupted source save",
|
||||
"If a staged multi-file save was interrupted, Textual model offers Recover interrupted save. Recovery checks the journal and file contents before restoring originals; it does not overwrite unrelated external changes. This is recovery of a save transaction, not periodic autosave of unsaved edits.",
|
||||
),
|
||||
explain(
|
||||
"Save work before leaving",
|
||||
"New, Open, loading the example and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. There is no autosave recovery journal in the current desktop; save meaningful milestones and use version control or backups.",
|
||||
@@ -685,7 +697,7 @@ fn article(topic: Topic) -> Article {
|
||||
sections: &[
|
||||
explain(
|
||||
"Editable formats",
|
||||
".arcadia is canonical text suitable for Git. .json is the typed model exchange format. Both preserve the supported persistent model, diagrams and traceability. Export SVG creates a diagram image for reports; it is not an editable model backup.",
|
||||
".arcadia is editable text suitable for Git and can include other source files. .json is the typed model exchange format. Both preserve the persistent model, diagrams and traceability; only a source project preserves its comments and file organization. Export SVG creates a diagram image for reports; it is not an editable model backup.",
|
||||
),
|
||||
example(
|
||||
"Keyboard reference",
|
||||
@@ -695,9 +707,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Save and share a model",
|
||||
&[
|
||||
"Use Save project to apply valid drafts and save to the current path. For a new project, select a destination and format in the file dialog.",
|
||||
"Use Save all to apply valid drafts and save the source project to the current path. For a new project, select a destination and format in the file dialog.",
|
||||
"Use Save as for a new path or explicit format conversion. Check the filename extension when selecting text or JSON.",
|
||||
"After saving, check All changes saved. Share the model file with its necessary external evidence locations, or commit it to version control.",
|
||||
"After saving, check All changes saved. Share or commit the entry and every included source file, together with the necessary external evidence locations.",
|
||||
"If another tool changed or deleted the open file, preserve your candidate using a new Save as destination. Compare and reconcile the versions before replacing either one.",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,8 @@ mod app;
|
||||
mod behavior;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
Flow,
|
||||
@@ -24,6 +25,7 @@ pub enum Action {
|
||||
impl Action {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Component => "Add component port",
|
||||
Self::Input => "Create function input port",
|
||||
Self::Output => "Create function output port",
|
||||
Self::Flow => "Create Flow component port",
|
||||
@@ -55,7 +57,7 @@ pub fn actions(p: &Project, id: Id) -> Vec<Action> {
|
||||
};
|
||||
match e.kind() {
|
||||
Kind::Function => vec![Action::Input, Action::Output],
|
||||
Kind::System | Kind::SystemActor => vec![Action::Flow, Action::Standard],
|
||||
Kind::System | Kind::SystemActor => vec![Action::Component, Action::Flow, Action::Standard],
|
||||
Kind::Port => match e.shared() {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
@@ -140,7 +142,7 @@ impl State {
|
||||
let kind = match action {
|
||||
Action::Input => ContractKind::Input,
|
||||
Action::Output => ContractKind::Output,
|
||||
Action::Standard => ContractKind::Standard,
|
||||
Action::Component | Action::Standard => ContractKind::Standard,
|
||||
Action::Resolve | Action::Split | Action::Synchronize
|
||||
if owner.is_some_and(|e| e.kind() == Kind::Function) =>
|
||||
{
|
||||
@@ -195,7 +197,9 @@ impl State {
|
||||
subject,
|
||||
id: Id::new_v4(),
|
||||
baseline: p.clone(),
|
||||
name: if action == Action::Generate {
|
||||
name: if action == Action::Component {
|
||||
"Boundary port".into()
|
||||
} else if action == Action::Generate {
|
||||
format!("{} implementation", p.named(subject))
|
||||
} else {
|
||||
format!("{kind} port")
|
||||
@@ -386,7 +390,12 @@ impl State {
|
||||
.width(Fill);
|
||||
if matches!(
|
||||
self.action,
|
||||
Action::Input | Action::Output | Action::Flow | Action::Standard | Action::Generate
|
||||
Action::Component
|
||||
| Action::Input
|
||||
| Action::Output
|
||||
| Action::Flow
|
||||
| Action::Standard
|
||||
| Action::Generate
|
||||
) && !reusing
|
||||
{
|
||||
body = body.push(
|
||||
@@ -395,7 +404,11 @@ impl State {
|
||||
.on_input(|v| event(Event::Name(v))),
|
||||
);
|
||||
}
|
||||
if matches!(self.action, Action::Resolve | Action::Generate) && !reusing {
|
||||
if matches!(
|
||||
self.action,
|
||||
Action::Component | Action::Resolve | Action::Generate
|
||||
) && !reusing
|
||||
{
|
||||
let kinds = if self.kind == ContractKind::Input || self.kind == ContractKind::Output {
|
||||
vec![ContractKind::Input, ContractKind::Output]
|
||||
} else {
|
||||
@@ -407,6 +420,9 @@ impl State {
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if self.action == Action::Component {
|
||||
body = body.push(text("Standard ports describe provided or required interfaces. Flow ports describe directional input/output. Both can be endpoints of a boundary exchange.").size(13));
|
||||
}
|
||||
if self.kind == ContractKind::Flow && self.action != Action::Generate {
|
||||
body = body.push(text("Flow orientation").size(12)).push(
|
||||
pick_list(
|
||||
@@ -571,7 +587,21 @@ impl State {
|
||||
button(text("Cancel"))
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(workflow::Event::Cancel)),
|
||||
button(text("Apply reviewed change")).on_press_maybe(
|
||||
button(text(
|
||||
if matches!(
|
||||
self.action,
|
||||
Action::Component
|
||||
| Action::Flow
|
||||
| Action::Standard
|
||||
| Action::Input
|
||||
| Action::Output
|
||||
) {
|
||||
"Create port"
|
||||
} else {
|
||||
"Apply reviewed change"
|
||||
}
|
||||
))
|
||||
.on_press_maybe(
|
||||
self.preview
|
||||
.as_ref()
|
||||
.is_ok_and(|v| !v.is_empty())
|
||||
|
||||
@@ -17,6 +17,144 @@ fn fixture() -> Project {
|
||||
p.relate(RelationKind::Allocation, "Performs", owner, work);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_explorer_follows_reparenting_without_changing_identity() {
|
||||
use crate::explorer::Group;
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "Platform", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Operating behavior", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Explore(Some(machine)));
|
||||
assert_eq!(app.explorer_group, Some(Group::Behavior));
|
||||
send(&mut app, Message::ExplorerCollapseAll);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Command(Command::Reparent {
|
||||
element: machine,
|
||||
parent: Some(system),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements[&machine].parent,
|
||||
Some(system),
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.selected, machine);
|
||||
assert_eq!(app.explorer_focus, Some(machine));
|
||||
assert_eq!(app.explorer_group, Some(Group::Participants));
|
||||
assert!(!app.explorer_collapsed.contains(&system));
|
||||
assert!(!app.explorer_groups_collapsed.contains(&Group::Participants));
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.explorer_group, Some(Group::Behavior));
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.explorer_group, Some(Group::Participants));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actor_port_creation_updates_containment_and_locate_reveals_its_owner() {
|
||||
let mut project = Project::empty();
|
||||
let actor = project
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "External operator", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project.clone());
|
||||
send(&mut app, Message::Explore(Some(actor)));
|
||||
send(
|
||||
&mut app,
|
||||
Message::PortAction(crate::port_workflow::Action::Component),
|
||||
);
|
||||
assert!(app.workflow.is_some(), "{}", app.status);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
let port = app.selected;
|
||||
assert_eq!(app.project().elements[&port].parent, Some(actor));
|
||||
assert!(matches!(
|
||||
port_contract(&app.project().elements[&port]),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(app.explorer_index.children(actor).contains(&port));
|
||||
send(&mut app, Message::ExplorerCollapseAll);
|
||||
send(&mut app, Message::Search("unrelated filter".into()));
|
||||
send(&mut app, Message::ExplorerLocate);
|
||||
assert!(app.search.is_empty());
|
||||
assert!(!app.explorer_collapsed.contains(&actor));
|
||||
assert!(
|
||||
!app.explorer_groups_collapsed
|
||||
.contains(&crate::explorer::Group::Participants)
|
||||
);
|
||||
send(&mut app, Message::AddRelation);
|
||||
let Some(Workflow::Relation(relation)) = &app.workflow else {
|
||||
panic!("boundary workflow")
|
||||
};
|
||||
assert_eq!(relation.kind, RelationKind::BoundaryExchange);
|
||||
assert_eq!(relation.source.as_ref().map(|c| c.id), Some(port));
|
||||
send(&mut app, Message::Workflow(Event::Cancel));
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert!(app.explorer_index.children(actor).is_empty());
|
||||
send(&mut app, Message::Redo);
|
||||
assert!(app.explorer_index.children(actor).contains(&port));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_source_prevents_property_draft_collision_and_preserves_both_if_present() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let original = app.project().clone();
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
let source = format!("// Review rationale\n{}", app.source_editor.text());
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
std::sync::Arc::new(source.clone()),
|
||||
))),
|
||||
);
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field(
|
||||
"/name".into(),
|
||||
"Must not enter a hidden property draft".into(),
|
||||
),
|
||||
);
|
||||
assert!(!app.draft_dirty());
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert_eq!(app.project(), &original);
|
||||
// A stale adapter or future integration must not let Apply drop an already
|
||||
// present property draft either. Preserve both and require an explicit choice.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Retained property draft".into());
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.apply_source());
|
||||
assert!(app.source_dirty());
|
||||
assert!(app.draft_dirty());
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert_eq!(app.project(), &original);
|
||||
send(&mut app, Message::RevertProperties);
|
||||
assert!(app.apply_source());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Reviewed model".into()),
|
||||
);
|
||||
send(&mut app, Message::Apply);
|
||||
assert_eq!(app.project().name, "Reviewed model");
|
||||
assert!(
|
||||
app.document.bundle().files[&app.document.bundle().entry]
|
||||
.starts_with("// Review rationale")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn system_only_models_open_the_populated_perspective() {
|
||||
let mut p = Project::empty();
|
||||
|
||||
+119
-166
@@ -40,6 +40,22 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn port_role(element: &oa_core::Element) -> String {
|
||||
match port_contract(element) {
|
||||
Some(PortContract::FunctionInput { .. }) => "Input".into(),
|
||||
Some(PortContract::FunctionOutput { .. }) => "Output".into(),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
}) => "Standard".into(),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}) => format!("Flow · {orientation:?}"),
|
||||
_ => "Legacy".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let header = container(
|
||||
@@ -145,6 +161,16 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
&& p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| e.layer() == app.layer && e.kind().is_performer());
|
||||
if crate::port_workflow::actions(p, app.selected)
|
||||
.contains(&crate::port_workflow::Action::Component)
|
||||
{
|
||||
creation = creation.push(
|
||||
button(text("+ Add port to selected…").size(12))
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||||
);
|
||||
}
|
||||
creation = creation.push(
|
||||
row![
|
||||
button(text("+ At root").size(11))
|
||||
@@ -180,76 +206,15 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
.size(12)
|
||||
.padding(9),
|
||||
);
|
||||
let query = app.search.to_lowercase();
|
||||
let mut elements = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == app.layer
|
||||
&& (query.is_empty()
|
||||
|| e.name.to_lowercase().contains(&query)
|
||||
|| e.alias
|
||||
.to_lowercase()
|
||||
.contains(query.trim_start_matches('@')))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
elements.sort_by_key(|e| (tree_path(p, e.id), e.alias.clone()));
|
||||
let mut explorer = column![].spacing(2);
|
||||
if elements.is_empty() {
|
||||
explorer = explorer.push(label(if query.is_empty() {
|
||||
"No elements in this perspective. Create your first element below."
|
||||
} else {
|
||||
"No matching elements in this perspective."
|
||||
}));
|
||||
}
|
||||
for e in elements {
|
||||
if query.is_empty() && tree_hidden(app, e.id) {
|
||||
continue;
|
||||
}
|
||||
let depth = ancestors(p, e.id).min(5);
|
||||
let children = p.children(e.id).next().is_some();
|
||||
explorer = explorer.push(row![
|
||||
button(
|
||||
text(if children {
|
||||
if app.explorer_collapsed.contains(&e.id) {
|
||||
"›"
|
||||
} else {
|
||||
"⌄"
|
||||
}
|
||||
} else {
|
||||
"·"
|
||||
})
|
||||
.size(14)
|
||||
.color(style::kind_color(e.kind()))
|
||||
)
|
||||
.style(style::nav)
|
||||
.padding([6, 3])
|
||||
.on_press_maybe(children.then_some(Message::CollapseExplorer(e.id))),
|
||||
button(column![text(short(&e.name, 29)).size(11), label(e.kind().label())].spacing(2))
|
||||
.padding(iced::Padding {
|
||||
left: 3. + depth as f32 * 8.,
|
||||
right: 6.,
|
||||
top: 6.,
|
||||
bottom: 6.
|
||||
})
|
||||
.width(Fill)
|
||||
.on_press(Message::Select(e.id))
|
||||
.style(if app.selected == e.id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
]);
|
||||
}
|
||||
nav = nav
|
||||
.push(
|
||||
scrollable(explorer.padding(iced::Padding {
|
||||
right: 10.,
|
||||
..Default::default()
|
||||
}))
|
||||
.id("model-explorer")
|
||||
.height(Fill),
|
||||
row![
|
||||
action("Collapse all", Message::ExplorerCollapseAll),
|
||||
action("Locate", Message::ExplorerLocate)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.push(crate::explorer_view::sidebar(app))
|
||||
.push(creation)
|
||||
.push(action("Project properties", Message::SelectProject).width(Fill));
|
||||
let sidebar = container(if app.page == Page::Text {
|
||||
@@ -258,12 +223,13 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
nav.into()
|
||||
})
|
||||
.padding(14)
|
||||
.width(230)
|
||||
.width(260)
|
||||
.height(Fill)
|
||||
.style(style::panel);
|
||||
let main = match app.page {
|
||||
Page::Diagram => diagram(app),
|
||||
Page::Catalog | Page::Data => catalog(app),
|
||||
Page::Catalog => crate::explorer_view::catalog(app),
|
||||
Page::Data => data_catalog(app),
|
||||
Page::Behavior => crate::behavior::view(app),
|
||||
Page::Traces => traces(app),
|
||||
Page::Text => source(app),
|
||||
@@ -361,49 +327,6 @@ fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Me
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn ancestors(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut depth = 0;
|
||||
while let Some(id) = current {
|
||||
depth += 1;
|
||||
if depth > 64 {
|
||||
break;
|
||||
}
|
||||
current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
depth
|
||||
}
|
||||
fn tree_hidden(app: &App, id: Id) -> bool {
|
||||
let mut current = app.project().elements.get(&id).and_then(|e| e.parent);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
return true;
|
||||
}
|
||||
if app.explorer_collapsed.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
current = app.project().elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
false
|
||||
}
|
||||
fn tree_path(p: &Project, id: Id) -> String {
|
||||
let mut chain = vec![];
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
if chain.len() > 64 {
|
||||
break;
|
||||
}
|
||||
let Some(e) = p.elements.get(&id) else {
|
||||
break;
|
||||
};
|
||||
chain.push(e.name.clone());
|
||||
current = e.parent;
|
||||
}
|
||||
chain.reverse();
|
||||
chain.join("/")
|
||||
}
|
||||
|
||||
fn diagram(app: &App) -> View<'_, Message> {
|
||||
let diagrams = app
|
||||
.project()
|
||||
@@ -566,6 +489,27 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn inspector(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
if app.source_dirty() {
|
||||
let mut notice = column![
|
||||
text("Source drafts pending").size(17),
|
||||
label("Properties become editable after source drafts are applied or reverted."),
|
||||
action("Open source drafts", Message::Page(Page::Text))
|
||||
]
|
||||
.spacing(12);
|
||||
if app.draft_dirty() {
|
||||
notice = notice
|
||||
.push(label(
|
||||
"A property draft is also retained. Revert it to apply the source drafts.",
|
||||
))
|
||||
.push(action("Revert property draft", Message::RevertProperties));
|
||||
}
|
||||
return container(notice)
|
||||
.padding(14)
|
||||
.width(320)
|
||||
.height(Fill)
|
||||
.style(style::panel)
|
||||
.into();
|
||||
}
|
||||
let tabs = row![
|
||||
action(
|
||||
"Properties",
|
||||
@@ -709,17 +653,66 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
let kind = p.elements.get(&app.selected).map(Element::kind);
|
||||
let port_actions = crate::port_workflow::actions(p, app.selected);
|
||||
if !port_actions.is_empty() {
|
||||
body = body.push(label("PORTS & EXCHANGES"));
|
||||
for port_action in port_actions {
|
||||
use crate::port_workflow::Action;
|
||||
if matches!(port_action, Action::Flow | Action::Standard) {
|
||||
continue;
|
||||
}
|
||||
let caption = match port_action {
|
||||
Action::Component => "+ Add port…",
|
||||
Action::Input => "+ Input port…",
|
||||
Action::Output => "+ Output port…",
|
||||
_ => port_action.label(),
|
||||
};
|
||||
body = body.push(action(caption, Message::PortAction(port_action)).width(Fill));
|
||||
}
|
||||
if matches!(kind, Some(Kind::System | Kind::SystemActor)) {
|
||||
body = body.push(label(
|
||||
"Ports belong to this participant and connect boundary exchanges.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let ports: Vec<_> = p
|
||||
.children(app.selected)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
if !ports.is_empty() {
|
||||
body = body.push(label(format!("OWNED PORTS · {}", ports.len())));
|
||||
for port in ports {
|
||||
body = body.push(
|
||||
action(
|
||||
&format!("{} · {}", port.name, port_role(port)),
|
||||
Message::Select(port.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
|
||||
{
|
||||
body = body.push(
|
||||
pick_list(
|
||||
port_actions,
|
||||
None::<crate::port_workflow::Action>,
|
||||
Message::PortAction,
|
||||
)
|
||||
.placeholder("Port and exchange actions…")
|
||||
.text_size(12)
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
button(text("Connect boundary exchange…").size(12))
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.on_press(Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
p.elements.get(&app.selected).is_some_and(|e| {
|
||||
matches!(
|
||||
port_contract(e),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In
|
||||
})
|
||||
)
|
||||
}),
|
||||
)),
|
||||
);
|
||||
body = body.push(label(
|
||||
"Choose the other participant's port in the next step.",
|
||||
));
|
||||
}
|
||||
let contextual: &[(&str, RelationKind, bool)] = match kind {
|
||||
Some(Kind::OperationalMission | Kind::Mission) => &[
|
||||
@@ -889,20 +882,11 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn catalog(app: &App) -> View<'_, Message> {
|
||||
let data = app.page == Page::Data;
|
||||
fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let mut content = column![heading(
|
||||
if data {
|
||||
"Data & interfaces"
|
||||
} else {
|
||||
"Model explorer"
|
||||
},
|
||||
if data {
|
||||
"Define information, material and energy at the system boundary."
|
||||
} else {
|
||||
"A shared model across every diagram, editor and automation."
|
||||
}
|
||||
"Data & interfaces",
|
||||
"Define information, material and energy at the system boundary."
|
||||
)]
|
||||
.spacing(15);
|
||||
for kind in Kind::ALL {
|
||||
@@ -910,7 +894,7 @@ fn catalog(app: &App) -> View<'_, Message> {
|
||||
kind,
|
||||
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
|
||||
);
|
||||
if data && !is_data {
|
||||
if !is_data {
|
||||
continue;
|
||||
}
|
||||
let elements = p
|
||||
@@ -960,33 +944,6 @@ fn catalog(app: &App) -> View<'_, Message> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if !data {
|
||||
content = content.push(text("Relationships").size(18));
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| p.layer_of(r.id) == Some(app.layer))
|
||||
{
|
||||
content = content.push(
|
||||
button(
|
||||
column![
|
||||
text(format!(
|
||||
"{} → {}",
|
||||
short(p.named(r.source()), 33),
|
||||
short(p.named(r.target()), 33)
|
||||
))
|
||||
.size(12),
|
||||
label(format!("{} · {}", r.kind(), r.name))
|
||||
]
|
||||
.spacing(5),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Select(r.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content.padding(24)).into()
|
||||
}
|
||||
|
||||
@@ -1140,11 +1097,7 @@ fn source_files(app: &App) -> View<'_, Message> {
|
||||
label("Includes define file membership. Closing a detached buffer never deletes its file."),
|
||||
]
|
||||
.spacing(9);
|
||||
if app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.code == "pending_save")
|
||||
{
|
||||
if app.pending_recovery.is_some() {
|
||||
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
|
||||
}
|
||||
panel.into()
|
||||
|
||||
@@ -169,33 +169,11 @@ impl Relationship {
|
||||
let Some(source) = self.source.as_ref().map(|source| source.id) else {
|
||||
return false;
|
||||
};
|
||||
let directed = validation::can_connect(p, self.kind, source, target);
|
||||
if self.kind != RelationKind::BoundaryExchange || self.oriented || directed {
|
||||
return directed;
|
||||
if self.kind == RelationKind::BoundaryExchange {
|
||||
validation::can_connect_boundary(p, source, target, self.oriented)
|
||||
} else {
|
||||
validation::can_connect(p, self.kind, source, target)
|
||||
}
|
||||
if ![source, target].iter().all(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|element| element.kind() == Kind::Port)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
// An unoriented connection may use the reverse stored endpoint order.
|
||||
// Ask the exact core rules, including preserved Legacy restrictions;
|
||||
// reversing can_connect alone would incorrectly relax those rules.
|
||||
let candidate = Relation {
|
||||
id: self.id,
|
||||
alias: p.fresh_alias("connection", "relation", self.id),
|
||||
name: "Connection".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
},
|
||||
};
|
||||
execute_batch(p, &[Command::CreateRelation(candidate)]).is_ok()
|
||||
}
|
||||
|
||||
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
|
||||
@@ -679,7 +657,15 @@ impl Workflow {
|
||||
_ => ("Source", "Target"),
|
||||
};
|
||||
content = content
|
||||
.push(text(d.kind.hint()).size(13).color(style::MUTED))
|
||||
.push(
|
||||
text(if d.kind == RelationKind::BoundaryExchange {
|
||||
"Component port ↔ component port"
|
||||
} else {
|
||||
d.kind.hint()
|
||||
})
|
||||
.size(13)
|
||||
.color(style::MUTED),
|
||||
)
|
||||
.push(
|
||||
pick_list(kinds, Some(d.kind), |v| {
|
||||
Message::Workflow(Event::RelationKind(v))
|
||||
@@ -709,7 +695,15 @@ impl Workflow {
|
||||
.width(Fill),
|
||||
);
|
||||
if d.source.is_some() && d.targets.is_empty() {
|
||||
content = content.push(text("No compatible target yet. Create the needed model element, then connect it here.").size(12).color(style::MUTED));
|
||||
content = content.push(
|
||||
text(if d.kind == RelationKind::BoundaryExchange {
|
||||
"No compatible component port on another participant. Select the other system or actor, add a port of the same kind (Standard or Flow), then connect it here. Flow ports also need compatible input/output orientations."
|
||||
} else {
|
||||
"No compatible target yet. Create the needed model element, then connect it here."
|
||||
})
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
if d.kind == RelationKind::CapabilityExtend {
|
||||
content = content.push(text("Extension condition").size(12)).push(
|
||||
@@ -1164,6 +1158,151 @@ fn choice(p: &Project, id: Id) -> Choice {
|
||||
mod port_authoring_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn boundary_picker_matches_exact_commands_across_contracts_and_ownership() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Internal work", None)
|
||||
.unwrap();
|
||||
let mut endpoints = vec![];
|
||||
let mut standard = vec![];
|
||||
for owner in [system, actor] {
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Unset,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::InOut,
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Flow",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
endpoints.push(id);
|
||||
}
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Standard",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
endpoints.push(id);
|
||||
standard.push(id);
|
||||
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
|
||||
let id = Id::new_v4();
|
||||
let mut port = make_port(&p, id, owner, "Legacy", PortContract::Legacy).unwrap();
|
||||
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction,
|
||||
interface: None,
|
||||
});
|
||||
}
|
||||
p.elements.insert(id, port);
|
||||
endpoints.push(id);
|
||||
}
|
||||
}
|
||||
for contract in [
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(&p, id, function, "Unrelated function port", contract).unwrap();
|
||||
p.elements.insert(id, port);
|
||||
endpoints.push(id);
|
||||
}
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Sa, "Telemetry", None)
|
||||
.unwrap();
|
||||
p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires telemetry",
|
||||
standard[0],
|
||||
interface,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides telemetry",
|
||||
standard[1],
|
||||
interface,
|
||||
);
|
||||
let missing_contract = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
missing_contract,
|
||||
actor,
|
||||
"Missing provision",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(missing_contract, port);
|
||||
endpoints.extend([missing_contract, system, function, Id::new_v4()]);
|
||||
assert!(execute_batch(&p, &[]).is_ok());
|
||||
for source in &endpoints {
|
||||
let Workflow::Relation(mut draft) = Workflow::relationship_kind(
|
||||
&p,
|
||||
*source,
|
||||
Layer::Sa,
|
||||
RelationKind::BoundaryExchange,
|
||||
false,
|
||||
) else {
|
||||
unreachable!()
|
||||
};
|
||||
// Include invalid sources as well as those shown by the UI.
|
||||
draft.source = Some(choice(&p, *source));
|
||||
for oriented in [false, true] {
|
||||
draft.oriented = oriented;
|
||||
for target in &endpoints {
|
||||
let exact = Relation {
|
||||
id: Id::new_v4(),
|
||||
alias: "boundary_probe".into(),
|
||||
name: "Boundary probe".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source: *source,
|
||||
target: *target,
|
||||
oriented,
|
||||
items: vec![],
|
||||
},
|
||||
};
|
||||
let accepted = execute_batch(&p, &[Command::CreateRelation(exact)]).is_ok();
|
||||
assert_eq!(
|
||||
draft.accepts_target(&p, *target),
|
||||
accepted,
|
||||
"source {source}, target {target}, oriented {oriented}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_component_port_connection_keeps_its_endpoint_and_direction() {
|
||||
for (kind, orientation) in [
|
||||
|
||||
+328
-40
@@ -623,7 +623,7 @@ a { text-decoration: none; }
|
||||
<h1 class="title">Arcadia Studio OA / SA Language and Editing Contract</h1>
|
||||
<dl class="docinfo simple">
|
||||
<dt class="version">Version<span class="colon">:</span></dt>
|
||||
<dd class="version">2</dd>
|
||||
<dd class="version">3</dd>
|
||||
<dt class="status">Status<span class="colon">:</span></dt>
|
||||
<dd class="status">Normative current implementation contract; first-release integration</dd>
|
||||
<dt class="core-implementation">Core implementation<span class="colon">:</span></dt>
|
||||
@@ -637,16 +637,14 @@ a { text-decoration: none; }
|
||||
<h2>1. Scope and conformance</h2>
|
||||
<p>This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from version 1. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.</p>
|
||||
<p>The first-release capability/mission, contextual process/chain, typed scenario
|
||||
and state/event additions
|
||||
are temporarily integrated under schema 2. This does not declare the first
|
||||
release complete or waive its remaining scenario, state, data, transition and
|
||||
usability requirements. A distinct release schema and explicit V2 migration
|
||||
will be specified after those interfaces are frozen. This document describes
|
||||
the current implemented records, rather than inventing compatibility rules for
|
||||
that future schema.</p>
|
||||
<p>Schema 3 introduces typed function and component ports, required functional
|
||||
exchange endpoints, independently oriented boundary exchanges, and explicit
|
||||
PortAllocation relations. First-release scenario/state additions remain in
|
||||
scope; this schema change does not declare native acceptance or the wider
|
||||
release complete. The preserved schema-2 reader validates the original
|
||||
contract before migration.</p>
|
||||
<p>OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
behavior. SA permits at most one System of Interest; its absence is an
|
||||
@@ -664,7 +662,7 @@ Actual verification evidence is separate from this contract.</p>
|
||||
</section>
|
||||
<section id="one-authoritative-model">
|
||||
<h2>2. One authoritative model</h2>
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (2), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (3), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
|
||||
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
|
||||
@@ -738,8 +736,8 @@ lists all additional fields. <span class="docutils literal">ref</span> means <sp
|
||||
<td><p><span class="docutils literal">[]</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_port</span>, <span class="docutils literal">sa_port</span></p></td>
|
||||
<td><p><span class="docutils literal">direction</span>: In, Out, InOut; <span class="docutils literal">interface</span>: ref?</p></td>
|
||||
<td><p>InOut; none</p></td>
|
||||
<td><p><span class="docutils literal">contract</span>: required PortContract; <span class="docutils literal">legacy</span>: optional LegacyPortContract</p></td>
|
||||
<td><p>No implicit contract; <span class="docutils literal">legacy</span> defaults none. OA admits Legacy only.</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_scenario</span>, <span class="docutils literal">sa_scenario</span></p></td>
|
||||
<td><p><span class="docutils literal">viewpoint</span>: ScenarioViewpoint; <span class="docutils literal">context</span>: ref?; <span class="docutils literal">precondition</span>, <span class="docutils literal">postcondition</span>: strings; <span class="docutils literal">steps</span>: occurrence refs</p></td>
|
||||
@@ -853,7 +851,7 @@ contain activities; functions contain functions. Capability grouping uses
|
||||
same-layer capability parents; it does not mean inheritance or inclusion.
|
||||
Operational and system missions are root semantic records; they do not own
|
||||
capabilities by containment. MissionCapability expresses that association.
|
||||
DataFields belong to ExchangeItems or Record DataTypes. Ports belong to
|
||||
DataFields belong to ExchangeItems or Record DataTypes. Preserved generic ports belong to
|
||||
participants or work. Lifelines belong to scenarios. State-machine ownership
|
||||
uses participant → machine → region → state/mode; a state or mode can contain
|
||||
a nested region. All other state vertices belong to Regions as well. Events
|
||||
@@ -889,11 +887,15 @@ references. Their kind is the declaration keyword:</p>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">functional_exchange</span></p></td>
|
||||
<td><p>SA function → SA function</p></td>
|
||||
<td><p><span class="docutils literal">source_port</span>, <span class="docutils literal">target_port</span> / none; <span class="docutils literal">items</span> / []</p></td>
|
||||
<td><p>Required <span class="docutils literal">source_port</span>, <span class="docutils literal">target_port</span> refs; <span class="docutils literal">items</span> / []</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">boundary_exchange</span></p></td>
|
||||
<td><p>Participant-owned output-compatible port → input-compatible port</p></td>
|
||||
<td><p><span class="docutils literal">items</span> refs / []</p></td>
|
||||
<td><p>Different participant owners; typed component ports have matching kinds</p></td>
|
||||
<td><p><span class="docutils literal">oriented</span> bool / true; <span class="docutils literal">items</span> refs / []</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">port_allocation</span></p></td>
|
||||
<td><p>SA Component port → SA FunctionInput/FunctionOutput port</p></td>
|
||||
<td><p>Requires the function's actual work Allocation to that component</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">capability_involvement</span></p></td>
|
||||
<td><p>Participant, activity/function, or OA role → capability</p></td>
|
||||
@@ -967,12 +969,68 @@ advisory warning; it does not prevent saving the model. Shared roles do not
|
||||
imply a unique performer.
|
||||
Allocation of a non-leaf work element is invalid. A computed graphical summary
|
||||
of its allocated leaves MUST NOT create an extra semantic allocation.</p>
|
||||
<p>Functional ports, when selected, belong to the corresponding endpoint function
|
||||
and have compatible direction. Boundary ports belong to participants and have
|
||||
compatible direction. An interface groups ExchangeItems; an exchange using
|
||||
that port interface must carry items supported by it. Studio's OA boundary
|
||||
exchange is a simplified representation of operational communication; it is
|
||||
not a claim of complete Capella CommunicationMean equivalence.</p>
|
||||
<p>Typed function ports belong directly to SA Functions. <span class="docutils literal">FunctionOutput</span>
|
||||
declares <span class="docutils literal">outgoing_items</span>; <span class="docutils literal">FunctionInput</span> declares <span class="docutils literal">incoming_items</span>.
|
||||
The lists contain distinct same-layer ExchangeItem references. A functional
|
||||
exchange MUST reference output/input ports owned by its respective functions,
|
||||
and each carried item MUST fit both declarations. Empty declarations accept
|
||||
no items. Item compatibility is identity equality; future specialization is
|
||||
not inferred from names. Port declarations remain independent of incident
|
||||
exchanges after creation.</p>
|
||||
<p>A <span class="docutils literal">Component</span> contract belongs to an SA System or SystemActor and stores
|
||||
<span class="docutils literal">kind</span> (Standard or Flow) and <span class="docutils literal">orientation</span> (Unset, In, Out, InOut).
|
||||
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
|
||||
component connections require matching kinds: Standard/Flow mismatch is an
|
||||
error. Standard connections normally use <span class="docutils literal">oriented false</span>; directional Flow
|
||||
connections use true. Boundary orientation is separate from endpoint kind and
|
||||
orientation. Oriented Flow endpoints permit Out at source and In at target;
|
||||
unoriented Flow endpoints must permit at least one communication direction.</p>
|
||||
<p>Provided/required interfaces are separate identified relations. A Standard
|
||||
partner satisfies a requirement by providing the identical interface; stored
|
||||
source/target order does not infer service roles. Component carried items must
|
||||
fit the declared interface-item union when explicit declarations exist. An
|
||||
explicitly empty interface is not a wildcard. Missing declarations and
|
||||
unsatisfied required interfaces produce located completeness warnings.</p>
|
||||
<p><span class="docutils literal">PortAllocation</span> relates a Component port to a typed function port only when
|
||||
that function has an actual Allocation to the component owner. Standard may
|
||||
allocate either function direction; Flow directions must agree. A typed
|
||||
ExchangeAllocation requires both corresponding PortAllocations and compatible
|
||||
carried items. Unoriented boundary exchanges allow reversed correspondence.
|
||||
Visual allocation nesting is never evidence of a stored allocation relation.</p>
|
||||
<p><span class="docutils literal">Legacy</span> requires <span class="docutils literal">legacy { direction <span class="pre">...;</span> interface <span class="pre">...;</span> }</span>. It preserves
|
||||
generic direction/interface data and emits a located compatibility warning.
|
||||
It is an explicit compatibility constructor in JSON/source; ordinary creation
|
||||
pickers do not offer it. Resolution to a typed SA contract retains this record
|
||||
without treating it as another active constraint. OA generic ports remain
|
||||
Legacy; OA Interactions stay activity-to-activity without function ports.
|
||||
Deleting a referenced legacy interface clears that optional live reference.</p>
|
||||
<p>A source-3 functional exchange MUST specify both port aliases; the parser does
|
||||
not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.</p>
|
||||
<p>For example:</p>
|
||||
<pre class="literal-block">arcadia 3;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
sa_exchange_item payload "Payload" {}
|
||||
sa_interface old_service "Preserved service" { items [@payload]; }
|
||||
sa_port output "Output" {
|
||||
parent @send;
|
||||
contract { FunctionOutput { outgoing_items [@payload]; }; };
|
||||
}
|
||||
sa_port input "Input" {
|
||||
parent @receive;
|
||||
contract { FunctionInput { incoming_items [@payload]; }; };
|
||||
}
|
||||
functional_exchange message "Message" {
|
||||
source @send; target @receive;
|
||||
source_port @output; target_port @input; items [@payload];
|
||||
}
|
||||
sa_port preserved "Unresolved original" {
|
||||
parent @send; contract Legacy;
|
||||
legacy { direction InOut; interface @old_service; };
|
||||
}</pre>
|
||||
<p>An exclusive region has at most one initial state. Missing initial state is
|
||||
a completeness warning. Initial states have no incoming transitions and at
|
||||
most one outgoing transition; their outgoing transition has no trigger/guard.
|
||||
@@ -1224,9 +1282,24 @@ Studio profile; no running history state is persisted or synthesized.</p>
|
||||
<section id="diagrams-artifacts-and-cross-layer-traceability">
|
||||
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
|
||||
<p><span class="docutils literal">diagram</span> has name, identity, alias, description, <span class="docutils literal">layer</span> (Oa by default),
|
||||
and <span class="docutils literal">projection</span> (Overview by default). Other projections are Decomposition,
|
||||
Allocation, and Traceability. Traceability diagrams can show both layers;
|
||||
<span class="docutils literal">projection</span> (Overview by default), and <span class="docutils literal">routes</span> (empty list by default).
|
||||
Other projections are Decomposition,
|
||||
Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
|
||||
other diagrams show their chosen layer.</p>
|
||||
<p>Allocation is an architecture view: it shows participants and work, nests
|
||||
allocated work under its performer, and displays interactions, functional
|
||||
exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
|
||||
and its exchanges. Capabilities shows participants, roles, missions and
|
||||
capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. Changing the projection hides
|
||||
inapplicable placements without deleting them or changing semantic records.</p>
|
||||
<p>Ports are calculated boundary anchors of visible owners, with their existing
|
||||
semantic identity. An explicitly placed port is not rendered as a separate
|
||||
large node. Functional exchanges use their declared source/target ports;
|
||||
boundary exchanges attach to their participant ports even if those ports have
|
||||
no separate placement. Collapsing an owner hides its interior; projected links
|
||||
may terminate at a visible ancestor without creating a new port or exchange.</p>
|
||||
<p><span class="docutils literal">node</span> has an alias and identity but no display-name string. Required fields
|
||||
are <span class="docutils literal">diagram</span> and <span class="docutils literal">element</span> references. Optional fields are <span class="docutils literal">position</span>
|
||||
(record with x/y, default 0/0), <span class="docutils literal">size</span> (width/height, default 224/94), and
|
||||
@@ -1237,6 +1310,50 @@ distinguishable from deleting it from the model.
|
||||
Changing a node's diagram preserves the placement identity, alias, coordinates,
|
||||
size, collapse state, and attached artifacts. The destination must accept the
|
||||
element's layer and must not already contain another placement of that element.</p>
|
||||
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
|
||||
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
|
||||
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
|
||||
at most once in a diagram's route list; different diagrams MAY store independent
|
||||
routes for the same edge. List order and explicit empty entries are retained by
|
||||
source and JSON round trips, although list order has no routing meaning. Edges
|
||||
need not currently be visible in their diagram.</p>
|
||||
<pre class="code text literal-block"><code>routes [
|
||||
{ edge @report_exchange;
|
||||
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; };
|
||||
}
|
||||
];</code></pre>
|
||||
<p>Waypoints are pinned absolute diagram positions in source-to-target traversal
|
||||
order; at most 128 are allowed per edge. Computed endpoints and automatic elbows
|
||||
MUST NOT be persisted as authored waypoints. A label record requires both
|
||||
<span class="docutils literal">fraction</span> (finite binary32 from 0 through 1, measured along the computed
|
||||
polyline's arc length) and <span class="docutils literal">offset</span> (a relative x/y diagram displacement).
|
||||
All position/displacement components obey the coordinate bounds in section 2.
|
||||
An absent label uses automatic placement. Unknown fields, missing required
|
||||
nested fields, duplicate route edges, non-edge references and invalid numbers
|
||||
are structural errors. A pinned point that obstructs routing remains stored;
|
||||
geometric routing difficulty is reported separately from structural validity.
|
||||
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
|
||||
points are bounded by the per-route limit and aggregate input byte limits.</p>
|
||||
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
|
||||
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
|
||||
removes it; removing an absent entry is a no-op for an existing diagram and edge.
|
||||
The latter is the Reset routing operation. Either command participates in the
|
||||
same atomic transactions, validation and undo as other model edits. Deleting a
|
||||
relation or trace removes its routes in every diagram; reidentifying it updates
|
||||
route references without changing geometry. Folding, hiding, changing projection
|
||||
or relocating placements MUST NOT erase routing metadata. Group movement
|
||||
translates pins when both actual endpoint owners move by the same delta; label
|
||||
offsets remain relative. Raw <span class="docutils literal">EditNode</span> and source position edits are exact
|
||||
record edits and do not implicitly translate pins.</p>
|
||||
<p>This is an additive schema-3 field. Earlier schema-3 JSON/source that omits
|
||||
<span class="docutils literal">routes</span> loads as an empty list. Frozen schema-1 and schema-2 readers do not
|
||||
accept this new field; migration creates empty route lists. Source projects keep
|
||||
routes in their owning diagram declaration/file, resolve <span class="docutils literal">edge @alias</span> across
|
||||
files, and retain route-entry comments by referenced edge identity when entries
|
||||
are reordered. The existing REST transaction endpoint accepts the same externally
|
||||
tagged command records with UUID references and conditional ETags; no separate
|
||||
routing endpoint or UI-only routing model exists.</p>
|
||||
<p><span class="docutils literal">artifact</span> has name, identity, alias, description, required <span class="docutils literal">owner</span> ref,
|
||||
<span class="docutils literal">relation</span> (References, Satisfies, Verifies, Implements, DerivedFrom;
|
||||
default References), <span class="docutils literal">uri</span> (default empty), <span class="docutils literal">revision</span> (optional string),
|
||||
@@ -1335,7 +1452,7 @@ spellings can be used in alias positions because references begin with <span cla
|
||||
<p>The grammar below uses <span class="docutils literal">*</span> for repetition and <span class="docutils literal">?</span> for optional syntax.
|
||||
<span class="docutils literal">kind</span> is exactly an element/relation keyword listed above. Typed field
|
||||
sets and enum constructors are closed by sections 3–5.</p>
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "2" ";" project
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "3" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = (kind | "diagram" | "artifact" | "trace")
|
||||
@@ -1369,6 +1486,77 @@ Ordinary string fields require quoted strings and cannot contain an unquoted
|
||||
<span class="docutils literal">@alias</span> reference. Enum fields require unquoted named values or their typed
|
||||
constructor records. Typed values MUST NOT nest more than 64 levels; malformed
|
||||
deeply nested drafts produce a diagnostic instead of exhausting the parser stack.</p>
|
||||
<section id="modular-source3-projects">
|
||||
<h3>6.1. Modular source3 projects</h3>
|
||||
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
|
||||
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source3 project header above. A project entry
|
||||
MAY also be <span class="docutils literal">include string ";"</span>. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur between declarations,
|
||||
never inside a typed field value. For example:</p>
|
||||
<pre class="literal-block">arcadia 3;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
include "oa/activities/activity_respond.arcadia";
|
||||
include "views/diagram_operational.arcadia";
|
||||
}</pre>
|
||||
<p>An include path is relative to its containing file. <span class="docutils literal">.</span> and <span class="docutils literal">..</span> segments
|
||||
normalize within the virtual project root; escaping that root is an error. Bundle
|
||||
keys MUST already be normalized, use forward slashes and the lowercase
|
||||
<span class="docutils literal">.arcadia</span> suffix, and contain no empty path segments. Absolute, drive, UNC,
|
||||
URL, backslash, control-character and non-portable Windows device paths are
|
||||
rejected. Paths ending a segment in dot or space are rejected. Keys that collide
|
||||
after Unicode lowercasing are rejected. References use the exact normalized
|
||||
spelling; filesystems do not participate in resolution.</p>
|
||||
<p>Every reachable file is compiled once, including through repeated or diamond
|
||||
includes. Include cycles are errors with their chain and including location.
|
||||
Missing files, duplicate JSON file keys and supplied unreachable files are
|
||||
errors. The compiler MUST NOT read local files, network resources, environment
|
||||
variables or the process working directory. A future native loader is responsible
|
||||
for translating a directory into this explicit bundle and enforcing physical
|
||||
path ownership; the pure compiler grants no filesystem access.</p>
|
||||
<p><span class="docutils literal">inspect_includes(path, source, is_entry, limits)</span> supports native discovery
|
||||
before all included documents are available. It uses the same strict entry or
|
||||
fragment syntax parser, applies document byte/record and local include-edge
|
||||
budgets, and returns normalized targets with exact directive spans. Comments and
|
||||
strings that mention include syntax are not directives. A syntax failure anywhere
|
||||
in the document returns located errors and no partial include list; it does not
|
||||
scan arbitrary token strings after failure. Global references and engineering
|
||||
validation are deferred to bundle compilation. A native loader MUST retain the
|
||||
unparsed draft on failure and enforce aggregate bytes/files/depth/edges while
|
||||
loading; this inspection call does not own or inspect a directory.</p>
|
||||
<p>All files share the existing global alias/UUID namespace. Files add no semantic
|
||||
namespace, implicit parent, allocation or port contract. Forward references work
|
||||
across files. Duplicate aliases and identities report the offending declaration
|
||||
and the previous declaration location, including collisions with the project.
|
||||
Moving a declaration between files MUST preserve the resulting model and UUIDs.</p>
|
||||
<p><span class="docutils literal">ProjectLimits</span> adds file, include-depth and include-edge budgets to the existing
|
||||
<span class="docutils literal"><span class="pre">language::ParseLimits</span></span>. Model bytes, graph objects, records and ordered
|
||||
references are counted across the complete bundle, never reset per file. The
|
||||
default candidate policy is 256 files, depth 32 (entry depth zero), 1,024 include
|
||||
edges and 64 MiB combined UTF-8 source. Other model counts remain caller policy.
|
||||
Repeated include edges count toward the edge budget; file contents count once.
|
||||
The compiler admits input and stabilized output against the same limits.</p>
|
||||
<p><span class="docutils literal">compile_bundle</span> returns either located errors and no candidate, or a
|
||||
<span class="docutils literal">CompiledSourceProject</span> with the validated <span class="docutils literal">DecodeResult</span>, stabilized bundle,
|
||||
derived <span class="docutils literal">SourceIndex</span> and located engineering warnings. It inserts explicit
|
||||
UUID clauses into headers that omitted them without otherwise reformatting the
|
||||
input. The returned source index and diagnostics address the returned stabilized
|
||||
bytes. Clients MUST retain that bundle after successful Apply; recompiling the
|
||||
original buffers with missing identities would generate fresh identities.</p>
|
||||
<p>Bundle diagnostics wrap the existing Diagnostic with a logical path and optional
|
||||
related locations. Declaration spans, top-level field value spans, project
|
||||
header/body/description spans and resolved include locations are available in
|
||||
the source index. Columns remain one-based Unicode scalars within the real file;
|
||||
spans never refer to concatenated or synthetic source.</p>
|
||||
<p>Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through their existing migration readers and reports. Modular projects
|
||||
require source3; migration is performed before modular export, not independently
|
||||
on included fragments. Modular source does not change the semantic JSON schema.</p>
|
||||
</section>
|
||||
</section>
|
||||
<section id="canonical-output-and-source-tooling">
|
||||
<h2>7. Canonical output and source tooling</h2>
|
||||
@@ -1385,10 +1573,73 @@ defaults, and JSON-escaped strings. Elements are emitted in UUID order, then
|
||||
relations; diagrams and their nodes follow, then artifacts, then traces.
|
||||
Fields use deterministic name order. Output ends with LF. Alias values come
|
||||
from the model, never from regenerating display names.</p>
|
||||
<p>Comments and input whitespace are lexical trivia. Canonicalizing or applying
|
||||
source does not preserve them; the editor MUST explain that behavior.
|
||||
<p>Comments and input whitespace are lexical trivia to the canonical writer.
|
||||
Canonical export and the current desktop single-document Apply path do not
|
||||
preserve them; the editor MUST explain that behavior. This existing path does not
|
||||
define an acceptable lossless authoring policy for a future project editor.
|
||||
The pure project compiler in section 6.1 preserves them while inserting missing
|
||||
identities. Project update/export distinctions are described below.
|
||||
Camera position, selection, source cursor, and undo history are not stored
|
||||
model fields. Layout and collapse state inside NodePlacement are model data.</p>
|
||||
<p>The pure <span class="docutils literal">format_bundle</span> exporter uses the same typed declaration writer as
|
||||
<span class="docutils literal">format</span>. It creates a small entry file, layer/category modules, separate
|
||||
activity/function roots and behavior contexts, and separate diagrams with their
|
||||
placements. Ports follow their owner; relations follow their source/context.
|
||||
Module names derive from persistent aliases, not display names or UUID chunks.
|
||||
Declarations are ordered by semantic ancestry and alias, with owners before
|
||||
descendants. Case-colliding generated file names receive deterministic numeric
|
||||
suffixes. When more than 240 distinct modules would be produced, aggregate
|
||||
folders consolidate into category modules to avoid thousands of tiny files.
|
||||
For every valid P, <span class="docutils literal"><span class="pre">compile_bundle(format_bundle(P)).decoded.project</span> == P</span>
|
||||
when the caller supplies sufficient admission budgets.</p>
|
||||
<p><span class="docutils literal">update_bundle(compiled, P, limits)</span> is a core source update facility. It
|
||||
checks that the compiled document/index/model snapshot still agrees, preserves
|
||||
existing declaration file ownership and all untouched document bytes, and
|
||||
recompiles the result before returning it. New children inherit their owner's
|
||||
file; new roots use canonical module rules and add an explicit entry include.
|
||||
Removed declarations do not delete source files. Alias changes also update
|
||||
declarations containing references to that alias. Invalid or stale updates
|
||||
return errors without mutating the input workspace.</p>
|
||||
<p>Updates MUST NOT canonicalize surviving declarations in full. Header, field and
|
||||
nested value positions are derived from the existing syntax parser. Unchanged
|
||||
fields retain their exact bytes, including field order, omitted defaults, number
|
||||
and string spelling, comments, indentation and line endings. Changed literals and
|
||||
references replace only the required tokens; a reference with comments between
|
||||
<span class="docutils literal">@</span> and its alias retains those comments. A previously omitted default is
|
||||
inserted only if its semantic value changes. New fields use the existing record's
|
||||
indentation and line-ending style without reformatting its other fields.</p>
|
||||
<p>A constructor tag change retains shared nested fields in place. Removed fields,
|
||||
constructors and declarations remove syntax tokens, retaining their comments
|
||||
and whitespace as annotations in the enclosing record or file. A complete value
|
||||
shape replacement likewise retains the old value's comments beside the new value.
|
||||
The updater does not invent an engineering interpretation for orphaned comments.</p>
|
||||
<p>List entries use stable referenced UUIDs, message-value <span class="docutils literal">field</span> references or
|
||||
lifeline-binding <span class="docutils literal">referenced</span> identities when these keys are unique. Literal
|
||||
entries use their literal value. Retained entries carry their authored value
|
||||
text and inter-separator comments when reordered. Trivia after a preceding comma
|
||||
belongs to the following entry; trivia before the next comma belongs to the
|
||||
preceding entry. Separators are reconstructed before trailing line comments so
|
||||
that the result remains valid syntax. Unkeyed or duplicate-key lists use source
|
||||
positions. Removed-entry comments remain as annotations in the list; newly added
|
||||
entries receive formatted syntax. These rules preserve comments without silently
|
||||
attaching new semantic identity to an ambiguous list entry.</p>
|
||||
<p>Retained declaration UUIDs and source ownership remain unchanged. Records match
|
||||
by UUID, not display name or alias. The model-only <span class="docutils literal">update_bundle</span> treats an
|
||||
explicit UUID replacement as removal and creation. The desktop instead uses
|
||||
<span class="docutils literal">update_bundle_with_identity_map</span> with the surviving-record provenance from
|
||||
<span class="docutils literal">prepare_command</span>: explicit identity edits and sequential identity swaps MUST
|
||||
preserve each original declaration's owning file and attached source trivia.
|
||||
New records have no original identity mapping. The mapping MUST be injective,
|
||||
refer to existing records and preserve record classes. The
|
||||
result is recompiled, reindexed and checked for equality with the requested
|
||||
model before return. Source changes needed for undo are workspace history data;
|
||||
callers should retain complete compiled workspace snapshots rather than infer
|
||||
past omission/layout choices from a model-only undo.</p>
|
||||
<p>Canonical modular export is a distinct, explicit operation and does not promise
|
||||
original comment/layout preservation. The native editor integrates per-file
|
||||
drafts and source-project saves as specified below. The pure compiler performs
|
||||
no filesystem access. REST source endpoints currently exchange a canonical
|
||||
model; they do not yet exchange the multi-file source bundle.</p>
|
||||
<p><span class="docutils literal">parse</span> returns a complete structurally validated candidate or error
|
||||
diagnostics and no model. Engineering warnings alone MUST NOT reject a valid
|
||||
candidate. Parsing may stop at the first syntax/conversion error. Diagnostics
|
||||
@@ -1431,8 +1682,9 @@ placement move are different transactions. The UI MUST identify which operation
|
||||
will occur. Reparenting work must not silently change its performer. A computed
|
||||
parent summary must remain distinguishable from direct allocation. A collapse
|
||||
operation hides presentation detail without deleting the children.</p>
|
||||
<p>Applying complete source is one model transaction. Graphical edits regenerate
|
||||
the matching canonical source. Both paths validate their candidate before
|
||||
<p>Applying a reachable source bundle is one model transaction. Graphical edits
|
||||
update its owning declarations through the source-preserving updater. Both
|
||||
paths validate their candidate and model/source equality before
|
||||
adoption; failure preserves the committed model, save baseline, and history.
|
||||
Incomplete but structurally safe references and coverage gaps produce warnings
|
||||
that allow continued authoring and saving.</p>
|
||||
@@ -1440,7 +1692,11 @@ that allow continued authoring and saving.</p>
|
||||
MUST NOT be replaced by generated text or lost on ordinary navigation. Undoing
|
||||
source-buffer edits and undoing committed model changes are distinct operations.
|
||||
Successful source Apply creates one model-history entry. Undo/redo restores
|
||||
the full stored model, including aliases, trace links, and occurrence identity.</p>
|
||||
the full stored model and exact accepted source bundle, including aliases,
|
||||
trace links, occurrence identity, comments and file ownership. Each open file
|
||||
retains its own unapplied draft across file navigation. Located lint diagnostics
|
||||
refer to the raw draft bytes rather than an identity-stabilized intermediate.
|
||||
Property editing MUST NOT silently discard a pending source draft.</p>
|
||||
<p>Changing a graphical UUID must atomically update map keys and every reference.
|
||||
Alias changes preserve UUID identity. Deletion must account for owned elements,
|
||||
incident relationships, ordered occurrences, and diagram placements without
|
||||
@@ -1449,14 +1705,24 @@ exchanges it references. Cross-layer counterparts are not owned descendants.
|
||||
Text edits describe a complete candidate and must remove or repair dependencies
|
||||
explicitly before Apply succeeds.</p>
|
||||
</section>
|
||||
<section id="persistence-and-v1-migration">
|
||||
<h2>9. Persistence and V1 migration</h2>
|
||||
<section id="persistence-and-explicit-migration">
|
||||
<h2>9. Persistence and explicit migration</h2>
|
||||
<p>Loading detects JSON from its first non-whitespace <span class="docutils literal">{</span>; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an <span class="docutils literal">.arcadia</span> suffix uses
|
||||
canonical V2 text; other suffixes use V2 JSON. Both forms preserve the same
|
||||
the accepted V3 source bundle (<span class="docutils literal">arcadia 3</span>); other suffixes use V3 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.</p>
|
||||
<p>During first-release schema-2 integration, old Scenario bodies without a
|
||||
<p>Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
writes; explicit recovery restores originals only when current bytes match a
|
||||
known transaction state. Unrelated external contents MUST NOT be overwritten
|
||||
by recovery. Missing entry files and lint updates MUST NOT hide a detected
|
||||
pending-recovery action. This is not cross-process compare-and-swap or atomic
|
||||
multi-file visibility. Save failures retain the accepted model and drafts.
|
||||
Detached dirty buffers MUST be reattached or reverted before source saving;
|
||||
JSON export does not replace the source project's saved baseline or location.</p>
|
||||
<p>The frozen schema-2 reader retains staged compatibility defaults. Old Scenario bodies without a
|
||||
<span class="docutils literal">viewpoint</span> retain MixedLegacy, and old Exchange occurrence bodies without
|
||||
<span class="docutils literal">message_kind</span>, <span class="docutils literal">reply_to</span> or <span class="docutils literal">values</span> retain Asynchronous, none and [].
|
||||
Old StateTransition bodies without <span class="docutils literal">transition_kind</span>, <span class="docutils literal">triggers</span> or
|
||||
@@ -1469,8 +1735,30 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults. A future schema-3 adapter remains a separate required
|
||||
migration and is not implied by this temporary compatibility behavior.</p>
|
||||
the added defaults through the explicit schema-3 migration.</p>
|
||||
<p>The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||
only a missing endpoint it creates an item-bearing FunctionOutput/Input owned
|
||||
by the already specified function, using UUID v5 in the project namespace
|
||||
with <span class="docutils literal"><span class="pre">v2-fe-port:<fe-id>:source</span></span> or <span class="docutils literal"><span class="pre">v2-fe-port:<fe-id>:target</span></span>. Every
|
||||
identity category is checked for collision; collisions are located errors,
|
||||
never overwrites. New aliases follow the deterministic collision-free alias
|
||||
convention. Every old boundary exchange receives <span class="docutils literal">oriented true</span>; no
|
||||
PortAllocation is inferred.</p>
|
||||
<p><span class="docutils literal">decode_with_report</span>, <span class="docutils literal">load_with_report</span> and their limited/source variants
|
||||
return the model plus a migration report containing original unresolved port
|
||||
IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 3. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.</p>
|
||||
<p>Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V3 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses this same V2-to-3 stage.</p>
|
||||
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
not truncate a previous file. External concurrent-writer conflict resolution
|
||||
@@ -1486,20 +1774,20 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V2 result, including repeated occurrences.
|
||||
input therefore yields the same V3 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.</p>
|
||||
<p>V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V2 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
V3 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
</section>
|
||||
<section id="complete-authoring-example">
|
||||
<h2>10. Complete authoring example</h2>
|
||||
<p>This document intentionally omits IDs for new authoring. Apply and Save write
|
||||
the assigned UUIDs explicitly.</p>
|
||||
<pre class="code text literal-block"><code>arcadia 2;
|
||||
<pre class="code text literal-block"><code>arcadia 3;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
|
||||
+31
-8
@@ -996,16 +996,23 @@ entries receive formatted syntax. These rules preserve comments without silently
|
||||
attaching new semantic identity to an ambiguous list entry.
|
||||
|
||||
Retained declaration UUIDs and source ownership remain unchanged. Records match
|
||||
by UUID, not display name or alias; an explicit UUID replacement is a removal
|
||||
and creation, retaining the removed record's comments in its original file. The
|
||||
by UUID, not display name or alias. The model-only ``update_bundle`` treats an
|
||||
explicit UUID replacement as removal and creation. The desktop instead uses
|
||||
``update_bundle_with_identity_map`` with the surviving-record provenance from
|
||||
``prepare_command``: explicit identity edits and sequential identity swaps MUST
|
||||
preserve each original declaration's owning file and attached source trivia.
|
||||
New records have no original identity mapping. The mapping MUST be injective,
|
||||
refer to existing records and preserve record classes. The
|
||||
result is recompiled, reindexed and checked for equality with the requested
|
||||
model before return. Source changes needed for undo are workspace history data;
|
||||
callers should retain complete compiled workspace snapshots rather than infer
|
||||
past omission/layout choices from a model-only undo.
|
||||
|
||||
Canonical modular export is a distinct, explicit operation and does not promise
|
||||
original comment/layout preservation. No native multi-file editor, project save
|
||||
transaction or REST bundle endpoint is claimed by the pure core APIs.
|
||||
original comment/layout preservation. The native editor integrates per-file
|
||||
drafts and source-project saves as specified below. The pure compiler performs
|
||||
no filesystem access. REST source endpoints currently exchange a canonical
|
||||
model; they do not yet exchange the multi-file source bundle.
|
||||
|
||||
``parse`` returns a complete structurally validated candidate or error
|
||||
diagnostics and no model. Engineering warnings alone MUST NOT reject a valid
|
||||
@@ -1054,8 +1061,9 @@ will occur. Reparenting work must not silently change its performer. A computed
|
||||
parent summary must remain distinguishable from direct allocation. A collapse
|
||||
operation hides presentation detail without deleting the children.
|
||||
|
||||
Applying complete source is one model transaction. Graphical edits regenerate
|
||||
the matching canonical source. Both paths validate their candidate before
|
||||
Applying a reachable source bundle is one model transaction. Graphical edits
|
||||
update its owning declarations through the source-preserving updater. Both
|
||||
paths validate their candidate and model/source equality before
|
||||
adoption; failure preserves the committed model, save baseline, and history.
|
||||
Incomplete but structurally safe references and coverage gaps produce warnings
|
||||
that allow continued authoring and saving.
|
||||
@@ -1064,7 +1072,11 @@ Source and property drafts contribute to unsaved-work state. Invalid drafts
|
||||
MUST NOT be replaced by generated text or lost on ordinary navigation. Undoing
|
||||
source-buffer edits and undoing committed model changes are distinct operations.
|
||||
Successful source Apply creates one model-history entry. Undo/redo restores
|
||||
the full stored model, including aliases, trace links, and occurrence identity.
|
||||
the full stored model and exact accepted source bundle, including aliases,
|
||||
trace links, occurrence identity, comments and file ownership. Each open file
|
||||
retains its own unapplied draft across file navigation. Located lint diagnostics
|
||||
refer to the raw draft bytes rather than an identity-stabilized intermediate.
|
||||
Property editing MUST NOT silently discard a pending source draft.
|
||||
|
||||
Changing a graphical UUID must atomically update map keys and every reference.
|
||||
Alias changes preserve UUID identity. Deletion must account for owned elements,
|
||||
@@ -1079,10 +1091,21 @@ explicitly before Apply succeeds.
|
||||
|
||||
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
||||
canonical V3 text (``arcadia 3``); other suffixes use V3 JSON. Both forms preserve the same
|
||||
the accepted V3 source bundle (``arcadia 3``); other suffixes use V3 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.
|
||||
|
||||
Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
writes; explicit recovery restores originals only when current bytes match a
|
||||
known transaction state. Unrelated external contents MUST NOT be overwritten
|
||||
by recovery. Missing entry files and lint updates MUST NOT hide a detected
|
||||
pending-recovery action. This is not cross-process compare-and-swap or atomic
|
||||
multi-file visibility. Save failures retain the accepted model and drafts.
|
||||
Detached dirty buffers MUST be reattached or reverted before source saving;
|
||||
JSON export does not replace the source project's saved baseline or location.
|
||||
|
||||
The frozen schema-2 reader retains staged compatibility defaults. Old Scenario bodies without a
|
||||
``viewpoint`` retain MixedLegacy, and old Exchange occurrence bodies without
|
||||
``message_kind``, ``reply_to`` or ``values`` retain Asynchronous, none and [].
|
||||
|
||||
@@ -30,3 +30,6 @@
|
||||
- Source/schema3 is now implemented. The original V2 comment-normalization decision is superseded for the first-release source-project work: validated bundle updates preserve concrete tokens/comments/whitespace, while explicit canonical export remains available. Desktop bundle editing and experimental nested syntax are separate acceptance gates; neither is implied by the pure-core updater.
|
||||
- Connection routing is per diagram, keyed by an existing relationship/trace ID. Store ordered pinned points and relative label placement; derive orthogonal elbows in a pure Rust core shared by canvas, picking, Fit and SVG. Typed port anchors never move to improve a route. Bounded automatic routing provides an initial path; manual pins express engineering layout intent. Collapse temporarily suspends authored routing when endpoints are promoted, preserving it for expansion. One pointer gesture commits one atomic command; stale/invalid-draft gestures are rejected.
|
||||
- Source workspace integration uses one accepted history of compiled source/model snapshots. The existing semantic Session remains for model-only consumers; a shared `prepare_command` supplies validated candidates and creation selection without committing or generating IDs twice. Desktop source writeback must succeed before adoption. Comment-only accepted edits are real history entries. Drafts and disk baselines stay separate; see release-source-integration.md for the implementation and acceptance contract.
|
||||
- Model navigation uses one cached semantic containment index for both outline and focused browser. Group only roots, preserve full depth, and show allocated work separately. Direct children and paged contextual search replace the flat catalog. The outline uses constant widgets per row; no new virtual-tree framework is introduced.
|
||||
- Port creation is explicit at the participant: Add port chooses Standard/Flow, then the selected port exposes Connect boundary exchange. Function Input/Output actions remain distinct. Candidate feasibility shares exact core validation with an explicit orientation parameter, avoiding per-choice project cloning and preserving Legacy behavior.
|
||||
- Recovery state is independent of source diagnostics. Interrupted Save All journals remain discoverable after lint, typing, missing entry files or first-save failure. Recovery validates current bytes against known transaction states; it does not promise atomic multi-file visibility or cross-process filesystem locking. Source/property draft guards prevent one representation from silently replacing pending edits in the other.
|
||||
|
||||
@@ -14,6 +14,8 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp
|
||||
|
||||
## Milestones and measurable acceptance
|
||||
|
||||
The user-directed actor/system port and model-navigation milestone is accepted: visible Add port → Connect boundary exchange, grouped roots, focused containment, breadcrumbs, contextual search and paging, clear full-depth outline, and separate allocations. Native Windows authoring/save/reopen, 251-root paging, 12-level navigation, 1440×900/1100×700 inspection, rebuilt Linux save/reopen and single-line font correction passed. Independent requirements, correctness, native UI and simplicity findings are addressed; release-explorer-usability.md records exact evidence and remaining performance boundaries. Resume source recovery acceptance and the release milestones below; the full scope remains intact.
|
||||
|
||||
Editable connection routing has passed code/native acceptance and independent follow-up at desktop checkpoint `23535b4`, with the Linux-discovered viewport correction in `366ce44` also verified on the actual rebuilt Linux app. Current WSL automated checks and routing runtime acceptance are recorded in release-routing-linux.md. Continue production source authoring under release-source-integration.md and direct behavior editing. Typed port integration and its native authoring/reopen checks are complete at `fa096ce`; `release-diagram-port-correction.md` records the domain correction. Existing R5 state authoring is preserved and still needs direct gestures and full native acceptance.
|
||||
|
||||
The user's routing correction is implemented as editable connection bend points with automatic obstacle-aware starting routes. Persistent commands, source/API parity, shared geometry and desktop gestures passed automated and native verification. `65fa9ab` resolves six default SA label-overlap pairs; follow-up typed reopen, short gestures and SVG export at 1100×700 passed. `release-routing-verification.md` records exact evidence, completed independent native follow-up and the 119 ms dense full-solve limitation. Route identity belongs to the diagram representation and does not alter semantic endpoints or ownership. Direct state gestures remain required and should reuse geometry/routing primitives only where semantics match.
|
||||
|
||||
+6
-2
@@ -8,6 +8,10 @@ The editable connection-routing milestone is accepted at `23535b4` with viewport
|
||||
|
||||
## Completed foundations
|
||||
|
||||
- The user-directed explorer/port usability milestone is accepted. Add port is visible on participants, and selected component ports expose Connect boundary exchange. The browser uses grouped roots, focused direct children, breadcrumbs, paged contextual search and Locate; the outline keeps real depth, guides and single-line labels with full-name tooltips. Root created two Standard ports and a Boundary Exchange, exercised Undo/Redo, Save/reopen, duplicate-name search, 251-root paging and 12-level navigation. Independent native review and root's final Windows build passed at 1100×700; actual rebuilt Linux save/reopen and font-fix inspection passed. Full Windows workspace tests passed 382, followed by all 138 desktop tests; Linux full workspace passed 386. Strict checks and final Windows/Linux builds passed. Slow picker, stale reparent focus, deep-row cost, collapsed header sizing, scroll reset and font wrapping findings are fixed. Exact scope, hashes and evidence: release-explorer-usability.md.
|
||||
- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Native source reopen/failure/minimum-size acceptance is still required.
|
||||
- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery still needs native acceptance.
|
||||
|
||||
- Branch `codex/release1-engineering`; baseline `93ba83d`, hierarchy `c7101b9`, drag selection `2ef16dc`, typed core/schema 3 merge `757ab88`, alias and batch lifecycle corrections `e41b8de`.
|
||||
- Typed desktop integration `fa096ce` provides contextual function/component ports, FE endpoint and exchange-item choices, generation, resolution, split and synchronization with semantic previews. Legacy migration, Standard/Flow distinctions and independent orientation are retained. Independent findings and native-discovered no-op synchronization and SA-only initial-perspective defects were fixed.
|
||||
- Native typed-port trials created real function endpoints and exchanges, supplementary output/items, and a Standard component implementation with two port allocations and an exchange allocation. Undo/redo and save passed with zero model checks. Reviewed reopen entered SA correctly; unchanged synchronization disabled Apply. The long-reference review modal was inspected at 1440×900 and 1100×700. Evidence and fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-typed-ports` and `ui-typed-ports-reviewed`.
|
||||
@@ -46,8 +50,8 @@ Earlier Studio windows disappeared during concurrent work. Fresh inventory confi
|
||||
|
||||
## Exact next actions
|
||||
|
||||
1. Native acceptance of release-source-integration.md is next. `86bf36b` preserves file/comment ownership through actual command identity provenance, including implicit ID+alias edits and sequential UUID swaps; it also exposes raw-draft lint locations. Independent Document probes originally reproduced all three ownership failures and now pass all seven probes (`source-document-independent-rerun.log`). `271f062` adds bounded source-project disk discovery, dependency conflict checks, staged recoverable Save All and explicit recovery. Fresh independent Linux probes found blocking FIFO reads in both discovery and physical-alias checks plus mode0664→0600 replacement; root fixed pre-open regular-file admission and permission preservation, then reran both probes (exit0, mode0664) and all11 Linux disk integration tests. This is still a non-CAS filesystem contract.
|
||||
Desktop integration is in the working tree: one accepted Document history, per-file drafts/navigation, whole-bundle lint/Apply, source Open/Save All and repair/recovery. Root fixed warning-only projects wrongly entering repair mode. All366 Windows workspace tests passed (223core,121desktop,22server), plus strict workspace Clippy. A follow-up adds an aggregate32MiB draft-history cap; its final desktop tests/build are running. The accepted model/source history separately has an estimated32MiB cap. The detached-file review finding is fixed at the UI lifecycle: closed buffers retain original dependency expectations until detach is saved, then receipts retire so later reattachment reads fresh disk bytes; its real disk integration test passes. Agents stopped with usage-limit errors, so root owns all remaining integration and native input. Do not claim the source UI accepted until native workflows and a fresh UI review run. Then extend production syntax and REST bundles; do not promote the experiment unchanged.
|
||||
1. Explorer/port acceptance is complete; preserve release-explorer-usability.md and the saved native/large-model fixtures. Finish native source comment-preserving reopen/error/recovery acceptance under release-source-integration.md.
|
||||
Source integration is committed at 3c87576; recovery/property guard follow-ups are implemented and tested with the explorer milestone. Identity provenance and conflict-aware Save All foundations are committed at 86bf36b/271f062. Root verified multi-file editing, Apply, Undo/Redo and Save natively; comment-preserving reopen and interrupted recovery still require runtime evidence. Preserve these tests/evidence rather than restarting integration. Then extend production nested syntax and REST bundles; do not promote the experiment unchanged.
|
||||
2. Linux routing acceptance is complete. Independent verification passed all 324 workspace tests, strict Clippy, formatting and optimized desktop/server builds at `23535b4`. Root's actual Linux/WSLg bend, label, Undo/Redo, Save and reopen passed. That run exposed a dialog-induced viewport recenter, fixed in `366ce44`: all 105 Windows desktop tests and strict desktop Clippy passed, Windows/Linux rebuilds passed, and root's rebuilt Linux native trial preserved the viewport throughout Save. `release-routing-linux.md` records exact scopes/hashes; final saved fixture is `ui-routing-viewport/linux-routing.arcadia` (33,546 bytes). The Linux test window was closed normally after saving. No full 325-test Linux invocation is claimed.
|
||||
3. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, navigable offline guidance and normative reST/API completion. These are required release work, not non-blocking limitations of a finished release.
|
||||
4. Run fresh requirements, correctness, native UI/UX and architecture reviews for the complete release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completion.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Actor ports and model navigation
|
||||
|
||||
User acceptance task: find a System Actor, create its component port, and connect a Boundary Exchange without discovering a hidden action menu. Navigate real containment in a large model without scanning one flat catalog.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Add port is visible on the selected System/Actor in the focused browser, Properties and creation area. The dialog chooses Standard or Flow explicitly; function Input/Output actions remain separate. A newly created port stays selected in the browser and exposes Connect boundary exchange.
|
||||
- Model explorer starts with semantic groups and opens one container's direct children. Breadcrumbs, separate allocated work, contextual search, 100-result pages and Locate provide navigation. Allocation is not presented as containment.
|
||||
- The cached index sorts siblings and walks full semantic depth. Outline rows indent the disclosure and label together, with a guide and kind color. Collapsed groups have finite width inside the horizontal scrollable. Each row has a constant widget count even at deep nesting.
|
||||
- Boundary candidate filtering uses the core's exact orientation-aware feasibility check, avoiding a full cloned/validated project per candidate. Both orientations, Legacy direction and Standard interface compatibility are preserved.
|
||||
|
||||
## Native Windows evidence
|
||||
|
||||
Windows build `22FC98F9343A3EE9E175F88E6C682B559C5E66B43CB5303AC990EA75E7F6CFE3` passed 136 desktop tests. Root operated its real native window: System perspective → Model explorer → System Actor → Add port; created a Standard actor port and a Standard system port with identical names; used Connect boundary exchange and the owner-labelled target picker; applied the exchange, inspected boundary symbols/connection, Undo/Redo, Save All. The saved 21-file fixture is `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-explorer-ports/native-port-exchange.arcadia`. It contains 43 objects, 26 links and zero checks. Both new ports and the exchange occur in `sa/participants.arcadia`. The runtime log is empty and the process closed normally.
|
||||
|
||||
Native search for `boundary port` showed both results with different owners and ancestor context. Opening a result focused the correct port. Locate revealed its owner. Collapse All exposed a real layout failure (group-only content had zero intrinsic width); root fixed finite group sizing and reset scrolling.
|
||||
|
||||
Independent native review of build `6F94FBC1B814570A3645BA5E6469B27AC04D9CA188FD65C97CF286A2039623A3` passed at 1440×900 and exactly 1100×700 client. It exercised grouped collapse, parent/child navigation, alias search, Locate, saved fixture reopen (43 objects, 26 links, zero checks), distinct contained ports/allocated work, Standard/Flow dialog choices and a valid Boundary Exchange preview. Root also inspected its minimum-size window. A retained outline scroll offset on layer changes was subsequently fixed and verified by root.
|
||||
|
||||
Root then opened the independent 263-function source fixture (251 roots, 12 nested levels) in build `1A0D1251DDBF9828D6F5161F57315CF378BF53B018E38E5086798F42DB470AA0`. Paging reset the browser viewport; searching `@review_deep_leaf` retained all ancestors, opening it and Locate revealed the correct leaf. OA's empty perspective and return to SA reset the outline correctly. Root added paging controls above the results after finding the bottom-only controls inconvenient. Native build `8D3326355A84952E0D75F859E8834487BCF443D1A5CC602253C5178119DA5358` verified pages 1–100, 101–200, 201–251, disabled Next on the final page, and Previous. Runtime logs were empty and these processes closed normally.
|
||||
|
||||
## Linux follow-up
|
||||
|
||||
The real Linux/WSLg application created a Standard port through the Actor's Add port action, selected it in the browser and exposed Connect boundary exchange. A first Save to a nonexistent test folder reported the exact filesystem error and retained the model; after the folder was created, Save All succeeded with 21 files and 42 objects, 25 links, zero checks. This WSL save to a Windows-mounted drive took tens of seconds; native Linux filesystem save timing is not established. Fixture: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-explorer-linux/port-trial.arcadia`.
|
||||
|
||||
Linux font metrics exposed wrapped outline labels inside fixed-height rows. The final code uses single-line text, clipping and full-name/alias tooltips. Root launched rebuilt Linux SHA256 `D05D25E09294A46430BC7DEDA4445CA0895F475EAC1C6E262C9BEE7CDFD7F735`, reopened the saved bundle through the actual Open dialog, switched to SA and browsed Actor → Boundary port. The owner and children remained distinct, long labels stayed on one line, the tooltip showed the full Actor name, and the saved port's connection action was visible. The log `explorer-linux-outline-runtime.log` is empty; the process closed normally.
|
||||
|
||||
## Automated verification
|
||||
|
||||
Logs are under `D:/CodexBuilds/Capella2-release1-01a06e2e`. Windows `explorer-workspace-final.log` passed 382 tests (223 core, 137 desktop, 22 server); the follow-up `explorer-desktop-accepted.log` passed all 138 desktop tests including same-identity reparent/Undo/Redo. Full workspace strict Clippy passed in `explorer-clippy-accepted.log`; affected desktop strict Clippy passed again after layer, pagination and font fixes. Linux `explorer-linux-workspace.log` passed all 386 tests (226 core, 138 desktop, 22 server), and full workspace strict Clippy passed. Windows and Linux final builds succeeded. Final `cargo fmt --all -- --check` passed. The final pagination/font edits affect presentation only and were verified natively; no later whole-workspace test invocation is claimed.
|
||||
|
||||
## Independent findings and fixes
|
||||
|
||||
- New port creation could leave browser actions targeted at a different owner than the displayed focus. Catalog focus/selection and Properties-tab integration were corrected and regression-tested.
|
||||
- Opening a Boundary Exchange picker among 1,000 mixed component/function ports took 10.657 seconds. Exact core feasibility reduced it to 15.156 ms; 3,000 ports take 136.999 ms. Six focused tests include 968 endpoint/orientation comparisons against full validated commands. Evidence: `D:/CodexBuilds/Capella2-explorer-usability-review/REVIEW.md`.
|
||||
- Valid same-identity reparenting could leave an old group breadcrumb and folded new ancestry. Root now refreshes the focused group and reveals ancestry whenever the accepted model changes; a Reparent/Undo/Redo regression is added.
|
||||
- A 400-level search path built 325,207 widget states. Root replaced per-ancestor widget groups with one depth-sized spacer and one guide per row, retaining actual indentation. Independent repro/retest project: `D:/CodexBuilds/Capella2-explorer-independent`.
|
||||
- The independent requirements/simplicity review found that collapse actions during search silently changed hidden expansion state. They are now disabled while search forces matching ancestry open. No unresolved blocking finding remains from the bounded explorer/port reviews.
|
||||
|
||||
Final Windows build SHA256 `3210042E61E95748386A57AD67120506F01A9EDB0A74DB40E36F94896C4068EC` passed root's native font-fix follow-up at exactly 1100×700 client (1102×732 captured frame). Root switched OA→SA, opened Model explorer, selected the Actor, inspected its owned port and separate allocated function, then opened and canceled Add port. Grouped rows, names/tooltips, breadcrumbs, direct actions and all dialog fields/buttons fit. The process closed normally and `explorer-windows-outline-runtime.log` is empty. The immediate explorer/port usability milestone is accepted. The full first-release goal remains incomplete; this document closes no unrelated milestone. Source recovery/error runtime acceptance and source-project REST support remain separate required work.
|
||||
Reference in New Issue
Block a user