Merge tested R6 data workflows into direct scenario workspace

This commit is contained in:
2026-09-06 05:04:04 +03:00
8 changed files with 638 additions and 216 deletions
+115 -3
View File
@@ -340,6 +340,7 @@ pub enum Message {
FieldShape(String, Value),
FieldAppend(String, Value),
FieldValuePath(String, crate::data_values::FieldPathKey),
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
FieldVariant(String, Value),
FieldArray(String, Option<usize>, i32),
Apply,
@@ -1467,6 +1468,18 @@ impl App {
self.status = error;
}
}
Message::FieldValueBrowse(path, scope, page) => {
if let Some(draft) = &mut self.draft
&& draft
.value
.pointer(&path)
.is_some_and(|row| row.get("expression").is_some())
{
draft
.value_browsers
.insert(path, crate::forms::ValueBrowser { scope, page });
}
}
Message::FieldValuePath(path, key) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.value_path(&path, key)
@@ -2947,8 +2960,14 @@ impl App {
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.status =
"Source project opened. Included files and comments are preserved.".into();
self.migration = self.document.compiled().decoded.migration.clone();
if self.migration.is_some() {
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
self.page = Page::Validation;
} else {
self.status =
"Source project opened. Included files and comments are preserved.".into();
}
}
Err(error) => {
self.canvas_document = Id::new_v4();
@@ -3578,7 +3597,9 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
disk.expectations.get(&disk.bundle.entry)
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
&& decoded.migration.is_some()
// Source1/2 require the historical single-document adapters.
// Source3+ must retain its authored bundle and token migration.
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
{
return Ok(LoadedSnapshot::Legacy(
path,
@@ -3663,6 +3684,97 @@ mod source_project_tests {
Ok(())
}
#[test]
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
for split in [false, true] {
let directory = Directory::new();
let path = directory.0.join("project.arcadia");
let mut authored = std::collections::BTreeMap::new();
if split {
let start = original.find("{\n").unwrap() + 2;
let end = original.rfind('}').unwrap();
authored.insert(
"project.arcadia",
format!(
"{} include \"dictionary.arcadia\";\n}}\n",
&original[..start]
),
);
authored.insert("dictionary.arcadia", original[start..end].to_owned());
} else {
authored.insert("project.arcadia", original.to_owned());
}
for (file, source) in &authored {
std::fs::write(directory.0.join(file), source).unwrap();
}
let mut app = App::with_project(Project::empty());
send(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
);
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
assert_eq!(app.page, Page::Validation);
assert_eq!(app.path, Some(path.clone()));
assert_eq!(app.document.bundle().files.len(), authored.len());
assert!(
app.dirty(),
"accepted source migration needs explicit saving"
);
for (file, source) in &authored {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let text = app
.document
.bundle()
.files
.values()
.cloned()
.collect::<String>();
assert!(text.contains("// Reused record parameters retain their own identities."));
assert!(text.contains("// Frozen authored schema3 compatibility input."));
let packet = app
.project()
.elements
.values()
.find(|e| e.alias == "packet")
.unwrap()
.id;
let origin = app
.project()
.elements
.values()
.find(|e| e.alias == "origin")
.unwrap()
.id;
assert_eq!(
app.document.compiled().index.lexical_owners.get(&origin),
Some(&packet)
);
let before = app.document.snapshot();
save(&mut app, path.clone()).unwrap();
assert!(!app.dirty());
for (file, source) in &before.bundle.files {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let mut reopened = App::with_project(Project::empty());
send(
&mut reopened,
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
);
assert!(reopened.migration.is_none());
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.document.bundle(), app.document.bundle());
assert!(!reopened.dirty());
}
}
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
let path = directory.0.join("project.arcadia");
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
+119 -45
View File
@@ -11,10 +11,11 @@ pub struct FieldPathKey {
pub struct FieldPathChoice {
pub key: FieldPathKey,
pub label: String,
pub picker_label: String,
}
impl std::fmt::Display for FieldPathChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
f.write_str(&self.picker_label)
}
}
@@ -49,8 +50,7 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
.map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| id.to_string())
})
.collect::<Vec<_>>()
.join(" → ");
.collect::<Vec<_>>();
let direction =
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
match entry.direction {
@@ -63,54 +63,97 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
} else {
""
};
let terminal = qualified
.last()
.expect("A field path always has a terminal");
let owner_path = qualified[..qualified.len() - 1].join(" → ");
let picker_label = format!(
"{terminal}{direction} — {}{}",
project.named(entry.item),
if owner_path.is_empty() {
String::new()
} else {
format!(" / {owner_path}")
},
);
FieldPathChoice {
key: FieldPathKey {
path: entry.path,
field: entry.field,
},
label: format!("{} / {qualified}{direction}", project.named(entry.item)),
label: format!(
"{} / {}{direction}",
project.named(entry.item),
qualified.join(" → ")
),
picker_label,
}
}
// A bounded picker must expose its distinguishing field before a long path.
// Keep the full address separately for the wrapping detail text.
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
choices
}
#[cfg(test)]
pub fn choices(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
) -> Result<Vec<FieldPathChoice>, String> {
Ok(data::try_carried_field_paths(project, exchange)?
.into_iter()
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect())
Ok(sorted_choices(
data::try_carried_field_paths(project, exchange)?
.into_iter()
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect(),
))
}
/// Direct carried fields only: an unrelated deep branch must not prevent
/// browsing a root field. Preserve the core resolver's first carried owner.
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
let mut seen = std::collections::BTreeSet::new();
exchange
.body
.items()
.iter()
.flat_map(|item| {
data::effective_fields(project, *item)
.into_iter()
.filter_map(|field| {
let SharedBody::DataField { direction, .. } = field.shared()? else {
return None;
};
Some(data::CarriedFieldPath {
item: *item,
path: vec![],
field: field.id,
direction: *direction,
sorted_choices(
exchange
.body
.items()
.iter()
.flat_map(|item| {
data::effective_fields(project, *item)
.into_iter()
.filter_map(|field| {
let SharedBody::DataField { direction, .. } = field.shared()? else {
return None;
};
Some(data::CarriedFieldPath {
item: *item,
path: vec![],
field: field.id,
direction: *direction,
})
})
})
})
.filter(|entry| seen.insert(entry.field))
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect()
})
.filter(|entry| seen.insert(entry.field))
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect(),
)
}
/// Browse one record at a time, including explicitly chosen recursive hops.
pub fn at_scope(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
scope: Option<&FieldPathKey>,
) -> Result<Vec<FieldPathChoice>, String> {
match scope {
Some(key) => children(project, exchange, phase, key),
None => Ok(roots(project, exchange, phase)),
}
}
/// Resolve authored finite paths as well as the finite representative picker list.
@@ -166,20 +209,22 @@ pub fn children(
}
let mut path = key.path.clone();
path.push(key.field);
Ok(data::effective_fields(project, *record)
.into_iter()
.map(|field| {
named_choice(
project,
data::CarriedFieldPath {
item: entry.item,
path: path.clone(),
field: field.id,
direction: entry.direction,
},
)
})
.collect())
Ok(sorted_choices(
data::effective_fields(project, *record)
.into_iter()
.map(|field| {
named_choice(
project,
data::CarriedFieldPath {
item: entry.item,
path: path.clone(),
field: field.id,
direction: entry.direction,
},
)
})
.collect(),
))
}
#[cfg(test)]
@@ -482,4 +527,33 @@ pub(crate) mod tests {
.contains("128")
);
}
#[test]
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let context = FieldPathKey {
path: vec![f.origin, next, next],
field: next,
};
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
let leaf = visible
.iter()
.find(|choice| choice.key.field == f.leaf)
.unwrap();
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
assert!(
visible.windows(2).all(
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
)
);
assert_ne!(
visible[0].to_string().chars().take(24).collect::<String>(),
visible[1].to_string().chars().take(24).collect::<String>()
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
}
}
+19 -4
View File
@@ -32,6 +32,12 @@ pub struct Input {
pub raw: String,
pub editor: text_editor::Content,
}
#[derive(Clone, Default)]
pub struct ValueBrowser {
pub scope: Option<crate::data_values::FieldPathKey>,
pub page: usize,
}
pub struct Draft {
pub kind: RecordKind,
pub original: Id,
@@ -40,6 +46,7 @@ pub struct Draft {
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
impl Draft {
@@ -77,11 +84,14 @@ impl Draft {
inputs: BTreeMap::new(),
exact_strings: false,
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
draft.rebuild();
Some(draft)
}
fn rebuild(&mut self) {
// Array/variant edits can change the meaning of a row path.
self.value_browsers.clear();
self.inputs.clear();
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
match value {
@@ -710,10 +720,15 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
..
},
..
} => project
.relations
.get(id)
.and_then(|r| crate::data_values::choices(project, r, message_kind).ok()),
} => project.relations.get(id).and_then(|r| {
let scope = address_path.split_last().map(|(&field, path)| {
crate::data_values::FieldPathKey {
path: path.to_vec(),
field,
}
});
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
}),
_ => None,
})
.unwrap_or_default()
+211 -81
View File
@@ -330,97 +330,152 @@ fn message_value<'a>(
} => project.relations.get(id).map(|r| (r, message_kind)),
_ => None,
});
let choices = context
.map(|(r, phase)| crate::data_values::choices(project, r, phase))
.unwrap_or_else(|| Ok(vec![]));
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
match choices {
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
Ok(mut choices) => {
let current_key = current.as_ref().map(|v| crate::data_values::FieldPathKey {
path: v.path.clone(),
field: v.field,
});
let mut selected = current_key
let current_key = current
.as_ref()
.map(|value| crate::data_values::FieldPathKey {
path: value.path.clone(),
field: value.field,
});
let selected = context.and_then(|(exchange, phase)| {
current_key
.as_ref()
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
});
let browser = draft
.value_browsers
.get(path)
.cloned()
.unwrap_or_else(|| ValueBrowser {
scope: selected
.as_ref()
.and_then(|key| choices.iter().find(|c| &c.key == key))
.cloned()
.or_else(|| {
context.and_then(|(r, phase)| {
current_key
.as_ref()
.and_then(|key| crate::data_values::resolve(project, r, phase, key))
})
});
if let Some(choice) = &selected
&& !choices.iter().any(|c| c.key == choice.key)
{
choices.push(choice.clone());
}
if selected.is_none()
&& let Some(key) = current_key.clone()
&& !key.field.is_nil()
{
out = out.push(text("Current address needs review. Choose an explicit path to resolve a legacy, ambiguous or incompatible value.").size(11).color(style::MUTED));
let retained = crate::data_values::FieldPathChoice {
label: format!(
"Current address · {} / {}",
key.path
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(" → "),
project.named(key.field)
),
key,
};
selected = Some(retained.clone());
choices.push(retained);
}
let owned = path.to_owned();
out = out.push(
pick_list(choices, selected, move |choice| {
Message::FieldValuePath(owned.clone(), choice.key)
})
.placeholder("Choose a carried field path…")
.text_size(12)
.padding(8)
.width(Fill),
);
}
.and_then(|choice| crate::data_values::parent(&choice.key)),
page: 0,
});
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
if let Some(choice) = &selected {
out = out.push(text(choice.label.clone()).size(11));
} else if let Some(key) = &current_key
&& !key.field.is_nil()
{
out = out.push(
text(format!(
"Current address needs review: {} / {}. Choose an explicit field.",
key.path
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(" → "),
project.named(key.field)
))
.size(11)
.color(style::MUTED),
);
}
if let (Some((exchange, phase)), Some(current)) = (context, current) {
let key = crate::data_values::FieldPathKey {
path: current.path,
field: current.field,
};
if crate::data_values::resolve(project, exchange, phase, &key).is_some() {
if let Some(parent) = crate::data_values::parent(&key) {
out = out.push(
button(text("Up to enclosing field").size(11))
if let Some((exchange, phase)) = context {
let location = browser
.scope
.as_ref()
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
.map(|choice| choice.label)
.unwrap_or_else(|| "Root carried fields".into());
out = out.push(
text(format!("Browse: {location}"))
.size(11)
.color(style::MUTED),
);
if let Some(scope) = &browser.scope {
let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press(
Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0)
)]
.spacing(4);
if !scope.path.is_empty() {
navigation = navigation.push(
button(text("Root fields").size(11))
.style(style::nav)
.on_press(Message::FieldValuePath(path.into(), parent)),
.on_press(Message::FieldValueBrowse(path.into(), None, 0)),
);
}
match crate::data_values::children(project, exchange, phase, &key) {
Ok(children) if !children.is_empty() => {
let path = path.to_owned();
out = out.push(navigation);
}
match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) {
Ok(choices) => {
let count = choices.len();
let (visible, page, pages) = field_page(choices, browser.page);
// Selection detail stays visible independently of browser location/page.
let visible_selection = selected
.clone()
.filter(|choice| visible.iter().any(|c| c.key == choice.key));
let owned = path.to_owned();
out = out.push(
pick_list(visible, visible_selection, move |choice| {
Message::FieldValuePath(owned.clone(), choice.key)
})
.placeholder("Choose a field here…")
.text_size(12)
.padding(8)
.width(Fill),
);
if pages > 1 {
out = out.push(
pick_list(
children,
None::<crate::data_values::FieldPathChoice>,
move |choice| Message::FieldValuePath(path.clone(), choice.key),
)
.placeholder("Choose a field inside this record…")
.text_size(12)
.padding(8)
.width(Fill),
row![
button(text("Previous").size(11))
.style(style::nav)
.on_press_maybe((page > 0).then(|| Message::FieldValueBrowse(
path.into(),
browser.scope.clone(),
page - 1
))),
text(format!("{} / {pages} · {count} fields", page + 1)).size(11),
button(text("Next").size(11))
.style(style::nav)
.on_press_maybe((page + 1 < pages).then(|| {
Message::FieldValueBrowse(
path.into(),
browser.scope.clone(),
page + 1,
)
}))
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
} else if count == 0 {
out = out.push(
text("No compatible fields at this location.")
.size(11)
.color(style::MUTED),
);
}
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
_ => {}
}
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
}
if let Some(choice) = &selected
&& browser.scope.as_ref() != Some(&choice.key)
&& crate::data_values::children(project, exchange, phase, &choice.key)
.is_ok_and(|children| !children.is_empty())
{
out = out.push(
button(
text(format!(
"Browse fields in {} →",
project.named(choice.key.field)
))
.size(11),
)
.style(style::nav)
.on_press(Message::FieldValueBrowse(
path.into(),
Some(choice.key.clone()),
0,
)),
);
}
} else {
out = out.push(
text("Choose a message exchange to browse its carried fields.")
.size(11)
.color(style::MUTED),
);
}
if let Some(expression) = value.get("expression") {
out = out.push(super::property(
@@ -435,6 +490,25 @@ fn message_value<'a>(
out.into()
}
const FIELDS_PER_PAGE: usize = 24;
fn field_page(
choices: Vec<crate::data_values::FieldPathChoice>,
requested: usize,
) -> (Vec<crate::data_values::FieldPathChoice>, usize, usize) {
let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1);
let page = requested.min(pages - 1);
(
choices
.into_iter()
.skip(page * FIELDS_PER_PAGE)
.take(FIELDS_PER_PAGE)
.collect(),
page,
pages,
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -490,6 +564,25 @@ mod tests {
format!("{path}/expression"),
"newly typed".into(),
));
let before_browse = app.draft.as_ref().unwrap().materialize().unwrap();
for (scope, page) in [
(None, 0),
(
Some(crate::data_values::FieldPathKey {
path: vec![f.origin],
field: next,
}),
999,
),
] {
let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page));
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
before_browse
);
assert_eq!(app.project(), &f.project);
}
let _ = app.update(Message::FieldValuePath(
path.into(),
crate::data_values::FieldPathKey {
@@ -553,6 +646,43 @@ mod tests {
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
}
#[test]
fn field_browser_pages_every_direct_field_without_flattening_other_records() {
let f = crate::data_values::tests::wide_payload();
let exchange = &f.project.relations[&f.exchange];
let roots =
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap();
assert!(roots.len() > FIELDS_PER_PAGE);
assert!(roots.iter().all(|choice| choice.key.path.is_empty()));
let scope = crate::data_values::FieldPathKey {
path: vec![],
field: f.origin,
};
let children =
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope))
.unwrap();
assert!(children.len() > FIELDS_PER_PAGE);
assert!(
children
.iter()
.all(|choice| choice.key.path == vec![f.origin])
);
for choices in [roots, children] {
let (_, _, pages) = field_page(choices.clone(), 0);
let visited: Vec<_> = (0..pages)
.flat_map(|page| {
let (visible, actual, _) = field_page(choices.clone(), page);
assert_eq!(actual, page);
assert!(visible.len() <= FIELDS_PER_PAGE);
visible
})
.collect();
assert_eq!(visited, choices);
assert_eq!(field_page(choices, usize::MAX).1, pages - 1);
}
assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1));
}
#[test]
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
let mut p = Project::empty();