//! 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())) .map(|snapshot| snapshot.with_architecture(self.architecture.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 .behavior .creation .pending_layer() .or_else(|| 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 }; if let Some(saved) = restored.architecture { self.active_diagram = Some(saved.diagram); self.layer = self.project().diagrams[&saved.diagram].layer; self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.page = Page::Diagram; self.architecture = architecture_authoring::State::restore(self, saved).ok(); } self.status = if let Some(report) = &self.migration { format!( "Recovered source {} as source {} 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.", report.from_schema, report.to_schema ) } else { "Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into() }; let lint = self.request_lint(); if self .architecture .as_ref() .is_some_and(architecture_authoring::State::inline) { Task::batch([lint, iced::widget::operation::focus("architecture-name")]) } else if self.page == Page::Behavior && self.behavior.source_open() { Task::batch([ lint, iced::widget::operation::focus(behavior::source_draft::NAME_INPUT), ]) } else if self.page == Page::Behavior && self.behavior.creation.created().is_some() { Task::batch([ lint, iced::widget::operation::focus(behavior::creation::NAME_INPUT), ]) } else { 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() } async fn run_recovery_tasks(app: &mut App, task: Task) -> (usize, bool, bool) { use iced::advanced::{Layout, layout, widget}; use iced::futures::StreamExt; // Mount real inputs, with focus initially in another control. This // verifies the returned operation targets the naming field by its ID. let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![ iced::widget::text_input("Engineering name", "Retained name") .id("architecture-name") .on_input(|_| ()), iced::widget::text_input("Other control", "Other value") .id("unrelated-field") .on_input(|_| ()) ] .into(); let mut tree = widget::Tree::new(fields.as_widget()); let node = fields.as_widget_mut().layout( &mut tree, &(), &layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)), ); tree.children[1] .state .downcast_mut::>() .focus(); let mut operations = 0; let mut lint_completions = 0; let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint"); while let Some(action) = stream.next().await { match action { iced_runtime::Action::Widget(mut operation) => { operations += 1; fields.as_widget_mut().operate( &mut tree, Layout::new(&node), &(), operation.as_mut(), ); } iced_runtime::Action::Output(message @ Message::Linted(_, _)) => { lint_completions += 1; let _ = app.update(message); } _ => panic!("Unexpected recovery task action"), } } assert_eq!( lint_completions, 1, "Autofocus must not replace request_lint" ); assert!(app.lint_running.is_none()); let focused = |index: usize| { tree.children[index] .state .downcast_ref::>() .is_focused() }; (operations, focused(0), focused(1)) } fn architecture_recovery_app() -> App { let mut app = App::with_project(Project::empty()); let _ = app.update(Message::AddDiagram); let _ = app.update(Message::Architecture(architecture_authoring::Event::Start( Kind::Entity, Some(Position { x: 80., y: 90. }), ))); let _ = app.update(Message::Architecture(architecture_authoring::Event::Name( "Accepted team".into(), ))); let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit)); assert!(app.architecture.is_none()); app } #[tokio::test] async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history() { use architecture_authoring::Event; for rename in [false, true] { let mut app = architecture_recovery_app(); let point = Position { x: 439., y: 328.2 }; let event = if rename { Event::Rename(app.route_context().unwrap(), app.selected, point) } else { Event::Start(Kind::Actor, Some(point)) }; let _ = app.update(Message::Architecture(event)); let _ = app.update(Message::Architecture(Event::Name( " Recovered engineering name 雨 ".into(), ))); let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap(); let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref()); let expected = snapshot.clone().restore().unwrap(); let checkpoint = snapshot.id; let mut recovered = App::with_project(Project::empty()); let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot))); assert_eq!( run_recovery_tasks(&mut recovered, task).await, (1, true, false) ); assert_eq!(recovered.page, Page::Diagram); assert!(recovered.architecture.as_ref().unwrap().inline()); assert_eq!( serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(), saved ); assert_eq!(recovered.project(), expected.document.project()); assert_eq!(recovered.document.bundle(), expected.document.bundle()); assert_eq!(recovered.document.revision(), expected.document.revision()); assert_eq!(recovered.document.can_undo(), expected.document.can_undo()); assert_eq!(recovered.document.can_redo(), expected.document.can_redo()); assert_eq!( recovered.source_workspace.bundle(), expected.workspace.bundle() ); assert_eq!(recovered.recovery.restored, Some(checkpoint)); assert!(recovered.path.is_none()); } } #[tokio::test] async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() { use architecture_authoring::Event; for context in ["accepted", "armed", "picker", "property", "source"] { let mut app = architecture_recovery_app(); match context { "armed" => { let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None))); } "picker" => { let _ = app.update(Message::Architecture(Event::Existing(None))); } "property" => { app.draft .as_mut() .unwrap() .input("/name", "Unapplied property".into()); assert!(app.draft_dirty()); } "source" => { app.source_workspace .edit("incomplete source 雨".into(), app.source_editor.cursor()); assert!(app.source_dirty()); } _ => {} } let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref()); let expected = snapshot.clone().restore().unwrap(); let mut recovered = App::with_project(Project::empty()); let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot))); assert_eq!( run_recovery_tasks(&mut recovered, task).await, (0, false, true), "{context}" ); assert!( !recovered .architecture .as_ref() .is_some_and(architecture_authoring::State::inline) ); assert_eq!(recovered.project(), expected.document.project()); assert_eq!(recovered.document.bundle(), expected.document.bundle()); assert_eq!(recovered.document.revision(), expected.document.revision()); assert_eq!( recovered.source_workspace.bundle(), expected.workspace.bundle() ); } } #[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")); } }