Files
ARCADIA-Studio/crates/oa-desktop/src/app/checkpoints.rs
T

508 lines
20 KiB
Rust

//! Home navigation and asynchronous private checkpoints share the normal guards.
use super::*;
use crate::{home, recovery};
impl App {
pub(super) fn remember_local(&mut self) {
if let Some(path) = self.path.clone() {
let name = self.project().name.clone();
if let Err(error) = self.home.remember(&path, &name) {
self.home.notice = Some(error);
}
}
}
pub(super) fn home_event(&mut self, event: home::Event) -> Task<Message> {
match event {
home::Event::New => self.handle(Message::Request(Pending::New)),
home::Event::Open => self.handle(Message::Request(Pending::Open)),
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
home::Event::Walkthrough => self.handle(Message::Request(Pending::Walkthrough)),
home::Event::Guide => self.update(Message::Page(Page::Guide)),
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
home::Event::OpenRecent(path) => {
self.recent_target = Some(path);
self.handle(Message::Request(Pending::Recent))
}
home::Event::Recover(id) => self.handle(Message::Request(Pending::Recover(id))),
home::Event::Forget(path) => {
if let Err(error) = self.home.forget(&path) {
self.home.notice = Some(error);
}
Task::none()
}
home::Event::Refresh => {
self.home.refresh();
self.recovery.refresh();
self.recovery.entries.retain(|entry| {
entry.id != self.canvas_document && Some(entry.id) != self.recovery.restored
});
Task::none()
}
}
}
pub(super) fn checkpoint_tick(&mut self) -> Task<Message> {
if self.recovery.writing || self.busy {
return Task::none();
}
if !self.dirty() {
self.retire_checkpoint();
return Task::none();
}
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
let id = self.canvas_document;
let snapshot = match recovery::Snapshot::capture(
id,
&self.document,
&self.source_workspace,
self.draft.as_ref(),
&self.behavior,
self.path.clone(),
self.selected,
)
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
{
Ok(snapshot) => snapshot,
Err(error) => {
self.recovery.warning = Some(error);
return Task::none();
}
};
self.recovery.writing = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::write(&directory, &snapshot))
.await
.map_err(|e| e.to_string())?
},
move |result| Message::CheckpointWritten(id, result),
)
}
pub(super) fn checkpoint_written(&mut self, id: Id, result: Result<(), String>) {
self.recovery.writing = false;
match result {
Ok(()) if id == self.canvas_document && self.dirty() => {
self.recovery.active = Some(id);
}
Ok(()) => {
if let Some(directory) = &self.recovery.directory
&& let Err(error) = recovery::remove(directory, id)
{
self.recovery.warning = Some(error);
}
}
Err(error) => {
self.recovery.warning = Some(format!(
"Recovery checkpoint failed: {error}. Save your work explicitly."
))
}
}
}
pub(super) fn retire_checkpoint(&mut self) {
let Some(directory) = &self.recovery.directory else {
return;
};
for id in [self.recovery.active.take(), self.recovery.restored.take()]
.into_iter()
.flatten()
{
if let Err(error) = recovery::remove(directory, id) {
self.recovery.warning = Some(error);
} else {
self.recovery.entries.retain(|e| e.id != id);
}
}
}
pub(super) fn load_checkpoint(&mut self, id: Id) -> Task<Message> {
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
self.busy = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::read(&directory, id).map(Arc::new))
.await
.map_err(|e| e.to_string())?
},
Message::CheckpointLoaded,
)
}
pub(super) fn checkpoint_loaded(
&mut self,
result: Result<Arc<recovery::Snapshot>, String>,
) -> Task<Message> {
self.busy = false;
let snapshot = match result {
Ok(s) => s,
Err(error) => {
self.status = error;
return Task::none();
}
};
let id = snapshot.id;
let restored = match (*snapshot).clone().restore() {
Ok(r) => r,
Err(error) => {
self.status = error;
return Task::none();
}
};
// Fully prepare recovery before touching the currently accepted document.
self.reset(restored.document.project().clone(), None);
self.hosted.leave_document();
self.document = restored.document;
self.migration = self.document.compiled().decoded.migration.clone();
self.selected = if self.project().layer_of(restored.selected).is_some()
|| restored.selected == self.project().id
{
restored.selected
} else {
self.project().id
};
// Installing the raw workspace must follow all accepted-source synchronization.
self.source_workspace = restored.workspace;
self.show_source_file(false);
self.refresh_draft();
if let Some(draft) = restored.property {
self.selected = draft.original;
self.exact_strings = draft.exact_strings;
self.draft = Some(draft);
}
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self.project().layer_of(focus) {
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
}
self.recovery.restored = Some(id);
self.recovery.entries.retain(|entry| entry.id != id);
self.page = if self.behavior.has_pending() {
Page::Behavior
} else if self.source_dirty() {
Page::Text
} else {
Page::Diagram
};
self.status = if self.migration.is_some() {
"Recovered source 5 as source 6 in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it."
} else {
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged."
}.into();
self.request_lint()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_resume_returns_to_each_actual_workspace_without_model_edits() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
for page in [
Page::Diagram,
Page::Catalog,
Page::Behavior,
Page::Data,
Page::Traces,
Page::Text,
Page::Validation,
Page::Guide,
] {
let _ = app.update(Message::Page(page));
assert_eq!(app.page, page);
let selected = app.selected;
let _ = app.update(Message::Page(Page::Home));
assert_eq!(app.last_workspace_page, page);
// Repeated Home events must not replace the remembered workspace.
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, page);
assert_eq!(app.selected, selected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn home_resume_keeps_state_view_while_explicit_architecture_remains_reachable() {
let mut project = Project::empty();
let owner = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
.unwrap();
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
project = prepare_command(&project, &plan.command).unwrap().project;
let mut app = App::with_project(project);
assert_eq!(app.page, Page::Behavior);
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, plan.diagram);
assert_eq!(app.behavior.active_owner, Some(machine));
let _ = app.update(Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert!(app.active_diagram.is_none());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Diagram);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn home_resume_retains_invalid_source_and_property_drafts() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Text));
let raw = format!("{}\nunfinished draft", app.source_workspace.text());
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Text);
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Field("/id".into(), "-".into()));
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(app.draft_dirty());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Data);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let raw = format!(
"{}\nunfinished walkthrough draft",
app.source_workspace.text()
);
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let task = app.update(Message::Home(home::Event::Walkthrough));
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
assert!(!app.busy);
assert!(iced_runtime::task::into_stream(task).is_none());
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(app.pending.is_none());
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
fn retained_snapshot(app: &App) -> recovery::Snapshot {
recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
app.path.clone(),
app.selected,
)
.unwrap()
}
#[test]
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().join("recovery"));
let id = app.project().id;
app.execute(Command::Rename(id, "Saved milestone".into()));
let path = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path,
None,
)
.unwrap();
app.execute(Command::Rename(id, "Later unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
let recovery_directory = app.recovery.directory.clone().unwrap();
recovery::write(&recovery_directory, &snapshot).unwrap();
app.recovery.active = Some(app.canvas_document);
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.dirty());
assert_eq!(app.project().name, "Later unsaved engineering");
assert_eq!(app.recovery.active, Some(app.canvas_document));
let restored = recovery::read(&recovery_directory, app.canvas_document)
.unwrap()
.restore()
.unwrap();
assert_eq!(
restored.document.project().name,
"Later unsaved engineering"
);
}
#[test]
fn checkpoint_completion_releases_the_writer_while_a_context_menu_is_open() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let id = app.project().id;
app.execute(Command::Rename(id, "Unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
recovery::write(directory.path(), &snapshot).unwrap();
app.recovery.writing = true;
let _ = app.update(Message::Menu(crate::model_menu::Event::Open(
crate::model_menu::Request::object(id, iced::Point::ORIGIN),
)));
assert!(app.model_menu.is_some());
let _ = app.update(Message::CheckpointWritten(app.canvas_document, Ok(())));
assert!(
!app.recovery.writing,
"A passive completion cannot be swallowed by an open menu"
);
assert_eq!(app.recovery.active, Some(app.canvas_document));
assert!(
app.model_menu.is_some(),
"Saving a checkpoint does not dismiss the menu"
);
assert_eq!(app.project().name, "Unsaved engineering");
}
#[test]
fn discard_and_close_waits_for_the_late_checkpoint_then_retires_it() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
app.execute(Command::Rename(
app.project().id,
"Discarded on close".into(),
));
let snapshot = retained_snapshot(&app);
let document = app.canvas_document;
app.recovery.writing = true;
let window = window::Id::unique();
let _ = app.update(Message::Request(Pending::Close(window)));
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
let closing = app.update(Message::Discard);
assert!(
iced_runtime::task::into_stream(closing).is_none(),
"The window must remain until its outstanding writer finishes"
);
assert_eq!(app.checkpoint_close, Some(window));
assert!(app.busy);
recovery::write(directory.path(), &snapshot).unwrap();
assert!(recovery::read(directory.path(), document).is_ok());
let close = app.update(Message::CheckpointWritten(document, Ok(())));
assert!(
iced_runtime::task::into_stream(close).is_some(),
"Close is emitted after checkpoint cleanup"
);
assert!(app.checkpoint_close.is_none());
assert!(recovery::read(directory.path(), document).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn recent_and_recovery_navigation_respect_unsaved_decision() {
let mut app = App::with_project(Project::demo());
let id = app.project().id;
app.execute(Command::Rename(id, "Retained work".into()));
let _ = app.update(Message::Home(home::Event::OpenRecent(PathBuf::from(
"missing.arcadia",
))));
assert!(matches!(app.pending, Some(Pending::Recent)));
assert!(!app.busy);
assert_eq!(app.project().name, "Retained work");
let _ = app.update(Message::Cancel);
let recovery_id = Id::new_v4();
let _ = app.update(Message::Home(home::Event::Recover(recovery_id)));
assert!(matches!(app.pending,Some(Pending::Recover(id)) if id==recovery_id));
assert!(!app.busy);
}
#[test]
fn restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding() {
let mut app = App::with_project(Project::demo());
let selected = app.project().id;
app.draft
.as_mut()
.unwrap()
.input("/name", "Not yet applied".into());
let snapshot = recovery::Snapshot::capture(
Id::new_v4(),
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
Some(PathBuf::from("original.arcadia")),
selected,
)
.unwrap();
let checkpoint_id = snapshot.id;
app.recovery.entries.push(recovery::Entry {
id: checkpoint_id,
name: "Retained work".into(),
origin: None,
modified: snapshot.modified,
});
let _ = app.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert!(app.path.is_none());
assert!(app.disk_project.is_none());
assert!(app.hosted.session.is_none());
assert!(app.document.dirty());
assert!(app.draft_dirty());
assert_eq!(app.recovery.restored, Some(checkpoint_id));
assert!(
app.recovery.entries.is_empty(),
"Home must not offer the copy currently being recovered again"
);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Not yet applied"
);
assert_ne!(app.project().name, "Not yet applied");
}
#[test]
fn stale_write_completion_cannot_recover_a_discarded_document() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let old = app.canvas_document;
let s = recovery::Snapshot::capture(
old,
&app.document,
&app.source_workspace,
None,
&app.behavior,
None,
app.selected,
)
.unwrap();
app.reset(Project::empty(), None);
recovery::write(directory.path(), &s).unwrap();
app.checkpoint_written(old, Ok(()));
assert!(recovery::read(directory.path(), old).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn malformed_checkpoint_does_not_replace_the_current_model() {
let mut app = App::with_project(Project::demo());
let original = app.document.bundle().clone();
let _ = app.checkpoint_loaded(Err("Damaged checkpoint".into()));
assert_eq!(app.document.bundle(), &original);
assert!(app.status.contains("Damaged"));
}
}