From 554cfbd9fe6fd64f0eb3012bcbb3efd7a95f7424 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 13 Sep 2026 05:06:50 -0700 Subject: [PATCH] Preserve diagnostic source authority when recovery removes save notice --- crates/oa-desktop/src/app.rs | 18 +- .../src/app/text_workspace/lifecycle_tests.rs | 155 ++++++++++++++++++ 2 files changed, 166 insertions(+), 7 deletions(-) diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index bec54c0a..1e80ec40 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -3390,13 +3390,17 @@ impl App { } self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again."); self.pending_recovery = None; - let diagnostics = self - .source_diagnostics - .iter() - .filter(|issue| issue.diagnostic.code != "pending_save") - .cloned() - .collect(); - self.publish_source_diagnostics(diagnostics); + // Removing the recovered-save notice changes presentation, + // not the source authority of the remaining locations. + let diagnostics_current = self + .text_workspace + .diagnostic_publication() + .is_some_and(|publication| publication.source() == self.source_key()); + self.source_diagnostics + .retain(|issue| issue.diagnostic.code != "pending_save"); + if diagnostics_current { + self.publish_source_diagnostics(self.source_diagnostics.clone()); + } match receipt.disk { Ok(disk) if !self.source_workspace.applied => { self.open_source_project(disk) diff --git a/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs b/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs index 4fc104a3..53b62878 100644 --- a/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs +++ b/crates/oa-desktop/src/app/text_workspace/lifecycle_tests.rs @@ -454,3 +454,158 @@ fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() { "receipt records accepted bytes while the newer draft remains unsaved" ); } + +/// Use an actual pending-save journal and the real recovery worker. The optional +/// reload error is injected only after recovery succeeded, so both receipt arms +/// exercise the same matching target without relying on filesystem races. +fn recover_warning_fixture(stale: bool, reload_error: bool) { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let path = root.join("project.arcadia"); + let journal = root.join(".arcadia-save-project.arcadia.journal"); + let mut app = fixture(); + save(&mut app, &path); + std::fs::create_dir(&journal).unwrap(); + std::fs::write( + journal.join("journal.json"), + serde_json::to_vec(&serde_json::json!({ + "version": 1, "entry": "project.arcadia", "files": [{ + "path": "project.arcadia", "before": true, "write": true + }] + })) + .unwrap(), + ) + .unwrap(); + std::fs::write(journal.join("0.before"), VALID).unwrap(); + std::fs::write( + journal.join("0.after"), + format!("{VALID}// interrupted save\r\n"), + ) + .unwrap(); + let disk = oa_core::source_files::load_project(&path, LIMITS).unwrap(); + app.pending_recovery = crate::app::recovery_target(&disk); + assert_eq!(app.pending_recovery.as_ref(), Some(&path)); + let pending = disk + .diagnostics + .into_iter() + .find(|issue| issue.diagnostic.code == "pending_save") + .unwrap(); + // Current-warning control includes newer unsaved bytes too. Recovery of an + // applied workspace must retain its drafts, not reload over them. + let draft = format!("{VALID}// retained draft\r\n"); + if !stale { + app.source_workspace + .edit(draft.clone(), app.source_editor.cursor()); + app.show_source_file(false); + } + let retained_warning = warning(&app); + app.publish_source_diagnostics(vec![retained_warning.clone(), pending]); + let show = Message::TextWorkspace(Event::ShowDrawer( + app.text_drawer_context(), + DrawerTab::Problems, + )); + send(&mut app, show); + let old = warning_messages(&app); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + if stale { + // This is the existing workspace-key departure seam: drafts advance + // the key while a previous diagnostic Vec can still await lint. + app.source_workspace + .edit(draft.clone(), app.source_editor.cursor()); + app.show_source_file(false); + assert_ne!(publication.source(), app.source_key()); + } else { + assert_eq!(publication.source(), app.source_key()); + } + assert!(app.source_workspace.applied && app.source_workspace.has_edits()); + reveal(&mut app); + let accepted = app.document.snapshot(); + let revision = app.document.revision(); + let raw = app.source_workspace.bundle(); + let key = app.source_key(); + let cursor = app.source_editor.cursor(); + let visit = app.text_workspace.drawer_visit(); + send(&mut app, Message::SourceRecover); + assert!( + app.busy, + "normal recovery request admitted with retained applied drafts" + ); + let mut receipt = crate::app::recover_snapshot(path.clone()).unwrap(); + assert_eq!(receipt.target, path); + assert!(receipt.disk.is_ok()); + assert!(!journal.exists(), "real recovery consumed the journal"); + if reload_error { + receipt.disk = Err("injected reload failure after successful recovery".into()); + } + send(&mut app, Message::SourceRecovered(Ok(receipt))); + assert!(!app.busy && app.pending_recovery.is_none()); + assert_eq!(app.source_diagnostics, vec![retained_warning]); + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.bundle(), raw); + assert_eq!(app.source_workspace.text(), draft); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Problems)); + assert_eq!(app.text_workspace.drawer_visit(), visit); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert_eq!(app.document.revision(), revision); + assert!(!app.document.can_undo() && !app.document.can_redo()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), VALID); + assert!(app.status.contains(if reload_error { + "files could not be reloaded" + } else { + "Current model and drafts are retained" + })); + if stale { + assert_eq!( + app.text_workspace.diagnostic_publication(), + Some(publication) + ); + assert!(app.source_annotation_snapshot().is_none()); + assert!( + app.text_diagnostic_message(SourceLocation { + path: app.source_diagnostics[0].path.clone(), + span: app.source_diagnostics[0].diagnostic.span, + }) + .is_none(), + "recovery must not manufacture a fresh stale-warning action" + ); + } else { + let current = app.text_workspace.diagnostic_publication().unwrap(); + assert_eq!(current.source(), key); + assert_ne!(current.generation(), publication.generation()); + assert!(app.source_annotation_snapshot().is_some()); + } + for message in old { + let interaction = app.source_symbols.interaction; + send(&mut app, message); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none()); + } + if !stale { + for message in warning_messages(&app) { + send(&mut app, message); + assert!( + app.source_symbols.reveal.is_some(), + "current primary and related remain live" + ); + reveal(&mut app); + } + } + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted)); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn recovery_retains_current_warning_authority_with_drafts_and_reload_failure() { + for reload_error in [false, true] { + recover_warning_fixture(false, reload_error); + } +} + +#[test] +fn recovery_does_not_republish_stale_warnings_after_workspace_key_departure() { + for reload_error in [false, true] { + recover_warning_fixture(true, reload_error); + } +}