feat: preserve nested source projects across desktop and REST
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user