feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
+140 -9
View File
@@ -165,15 +165,16 @@ impl Document {
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
let candidate = prepare_command(self.project(), &command)?;
if &candidate.project != self.project() {
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
}
// Equal model values can still carry different record provenance, such
// as deleting and recreating an identical record with a reused UUID.
// Only the resulting model/source pair determines whether this is a no-op.
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
Ok(candidate.selected)
}
@@ -369,6 +370,136 @@ mod tests {
assert_eq!(document.bundle(), &branch);
}
#[test]
fn nested_cross_file_reparent_and_source_edit_share_exact_history() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" { include \"oa/source.arcadia\"; include \"oa/target.arcadia\"; }\n".into(),
),
(
"oa/source.arcadia".into(),
"// Source module\r\noa_activity respond \"Respond\" {\r\n // Assessment rationale follows this activity.\r\n oa_activity assess \"Assess\" {}\r\n}\r\n".into(),
),
(
"oa/target.arcadia".into(),
"// Destination module\noa_activity coordinate \"Coordinate\" {}\n".into(),
),
].into(),
};
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
document.mark_saved(document.snapshot());
let original = document.snapshot();
let id = |alias: &str| {
original
.decoded
.project
.elements
.values()
.find(|element| element.alias == alias)
.unwrap()
.id
};
let child = id("assess");
let destination = id("coordinate");
document
.execute(Command::Reparent {
element: child,
parent: Some(destination),
})
.unwrap();
let moved = document.snapshot();
assert_eq!(
document.project().elements[&child].parent,
Some(destination)
);
assert_eq!(
document.compiled().index.declarations[&child].path,
"oa/target.arcadia"
);
assert!(
document.bundle().files["oa/target.arcadia"]
.contains("// Assessment rationale follows this activity.")
);
assert!(
!document.bundle().files["oa/source.arcadia"]
.contains("// Assessment rationale follows this activity.")
);
let edited = comment(document.bundle(), "// Reviewed containment decision.\n");
assert!(document.apply_bundle(edited.clone()).unwrap());
assert_eq!(document.project(), &moved.decoded.project);
assert!(document.undo());
assert_eq!(document.bundle(), &moved.bundle);
assert!(document.undo());
assert_eq!(document.bundle(), &original.bundle);
assert_eq!(document.project(), &original.decoded.project);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &moved.bundle);
assert!(document.redo());
assert_eq!(document.bundle(), &edited);
let accepted = document.snapshot();
let invalid = comment(document.bundle(), "invalid_declaration;");
assert!(document.apply_bundle(invalid).is_err());
assert!(Arc::ptr_eq(&accepted, &document.snapshot()));
let reopened =
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), document.project());
assert_eq!(reopened.bundle(), document.bundle());
assert!(!reopened.dirty());
}
#[test]
fn recreated_equal_record_does_not_inherit_deleted_source_ownership() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" { include \"authored.arcadia\"; }\n"
.into(),
),
(
"authored.arcadia".into(),
"// Rationale for the original record.\noa_activity assess \"Assess\" {}\n"
.into(),
),
]
.into(),
};
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
document.mark_saved(document.snapshot());
let before = document.snapshot();
let element = document.project().elements.values().next().unwrap().clone();
let id = element.id;
document
.execute(Command::Batch(vec![
Command::Delete(id),
Command::CreateElement(element),
]))
.unwrap();
assert_eq!(document.project(), &before.decoded.project);
assert_ne!(
document.compiled().index.declarations[&id].path,
"authored.arcadia"
);
assert!(
document.bundle().files["authored.arcadia"]
.contains("// Rationale for the original record.")
);
assert!(!document.bundle().files["authored.arcadia"].contains("oa_activity"));
assert!(document.dirty());
assert_eq!(document.revision(), 1);
let recreated = document.snapshot();
assert!(document.undo());
assert_eq!(document.bundle(), &before.bundle);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &recreated.bundle);
}
#[test]
fn asynchronous_save_marks_its_snapshot_without_cleaning_later_edits() {
let mut document = document();
+48 -6
View File
@@ -7,6 +7,7 @@ use iced::{
Element as View, Fill, Font,
widget::{
self, Space, button, column, container, pick_list, row, scrollable, text, text_input,
tooltip,
},
};
use oa_core::*;
@@ -1080,7 +1081,7 @@ fn source_files(app: &App) -> View<'_, Message> {
);
}
}
let mut panel = column![
let panel = column![
text("SOURCE FILES").size(13),
label(format!(
"{} files · one system model",
@@ -1097,9 +1098,6 @@ fn source_files(app: &App) -> View<'_, Message> {
label("Includes define file membership. Closing a detached buffer never deletes its file."),
]
.spacing(9);
if app.pending_recovery.is_some() {
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
}
panel.into()
}
@@ -1144,8 +1142,49 @@ fn source(app: &App) -> View<'_, Message> {
} else {
column![].into()
};
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
let edits_block_recovery =
!app.source_workspace.applied && app.source_workspace.has_edits();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
container(
column![
tooltip(
text(format!("Interrupted save · {filename}")).size(15),
text(path.display().to_string()).size(12),
tooltip::Position::Bottom,
),
text(if edits_block_recovery {
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
} else if app.source_workspace.applied {
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
} else {
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
})
.size(13),
button(text("Recover interrupted save").size(12))
.padding(10)
.on_press_maybe(
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
),
]
.spacing(8),
)
.padding(12)
.width(Fill)
.style(style::card)
.into()
} else {
column![].into()
};
let mut diagnostics = column![].spacing(5);
let mut visible_diagnostics = 0;
for (index, issue) in app.source_diagnostics.iter().enumerate() {
// The persistent recovery banner supplies the action and explanation;
// repeating the internal journal path obscures ordinary diagnostics.
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
continue;
}
visible_diagnostics += 1;
diagnostics = diagnostics.push(
button(
text(format!(
@@ -1216,8 +1255,11 @@ fn source(app: &App) -> View<'_, Message> {
]
.spacing(7)
.align_y(iced::Center),
recovery,
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
label(if !app.source_workspace.applied {
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
"The previous graphical model remains available while these files are recovered."
} else if !app.source_workspace.applied {
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
} else if app.source_workspace.detached_dirty() {
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
@@ -1226,7 +1268,7 @@ fn source(app: &App) -> View<'_, Message> {
}),
navigation,
scrollable(diagnostics)
.height((app.source_diagnostics.len() as f32 * 32.).min(132.))
.height((visible_diagnostics as f32 * 32.).min(132.))
]
.spacing(12)
)