//! 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 { 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 { 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 { 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, String>, ) -> Task { 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")); } }