From 33e30c1cd7a6c99513a0ebd02ba68fccbad8f58d Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 03:55:42 -0700 Subject: [PATCH] Keep repaired destinations unsaved until their accepted source is written --- crates/oa-desktop/src/app.rs | 11 + .../src/app/repair_save_state_tests.rs | 263 ++++++++++++++++++ crates/oa-desktop/src/document.rs | 22 +- 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 crates/oa-desktop/src/app/repair_save_state_tests.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 40320fe2..234a1a21 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -3037,11 +3037,17 @@ impl App { || self.path.as_ref() == Some(&receipt.target) { self.path = Some(receipt.target); + self.document.bind_repair_baseline(disk.reachable_bundle()); self.disk_project = Some(disk); } self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into(); } Err(error) => { + if self.path.is_none() + || self.path.as_ref() == Some(&receipt.target) + { + self.document.invalidate_saved_baseline(); + } self.status = format!( "Disk recovery completed; files could not be reloaded: {error}" ) @@ -3858,6 +3864,7 @@ impl App { self.canvas_document = Id::new_v4(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.path = Some(path); + self.document.bind_repair_baseline(disk.reachable_bundle()); self.source_workspace = Workspace::new(&disk.bundle, false); self.source_workspace.reachable = disk.reachable.clone(); self.show_source_file(false); @@ -4525,6 +4532,10 @@ fn read_snapshot(path: PathBuf) -> Result { #[path = "app/save_as_tests.rs"] mod save_as_tests; +#[cfg(test)] +#[path = "app/repair_save_state_tests.rs"] +mod repair_save_state_tests; + #[cfg(test)] mod source_project_tests { use super::*; diff --git a/crates/oa-desktop/src/app/repair_save_state_tests.rs b/crates/oa-desktop/src/app/repair_save_state_tests.rs new file mode 100644 index 00000000..a60eb435 --- /dev/null +++ b/crates/oa-desktop/src/app/repair_save_state_tests.rs @@ -0,0 +1,263 @@ +use super::*; + +const VALID: &str = concat!( + "arcadia 7;\r\n", + "project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n", + " oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n", + "}\r\n", +); + +fn open(app: &mut App, path: &std::path::Path) { + let _ = app.update(Message::Loaded(Ok(Some( + read_snapshot(path.to_owned()).unwrap(), + )))); +} + +fn edit(app: &mut App, text: &str) { + let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(text.into())), + ))); +} + +fn save(app: &mut App, path: &std::path::Path) { + let receipt = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + path.to_owned(), + app.disk_project.clone(), + ) + .unwrap(); + let _ = app.update(Message::Saved(Ok(Some(receipt)))); +} + +#[test] +fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() { + for extra in [ + " include \"missing.arcadia\";\r\n", + " oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n", + ] { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let second_path = second + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n"; + std::fs::write(&first_path, VALID).unwrap(); + std::fs::write(&second_path, &invalid).unwrap(); + let mut app = App::with_project(Project::empty()); + open(&mut app, &first_path); + assert!(!app.dirty()); + let before = app.document.snapshot(); + let revision = app.document.revision(); + open(&mut app, &second_path); + assert!(!app.source_workspace.applied); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + edit(&mut app, VALID); + assert!(app.apply_source(), "{}", app.status); + assert!(app.source_workspace.applied); + assert!(!app.source_dirty()); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.revision(), revision); + assert!( + !app.document.can_undo(), + "Equal content creates no history step" + ); + assert!(app.document.dirty()); + assert_eq!(app.hosted_label(), "Unsaved changes"); + assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes()); + assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes()); + let close = window::Id::unique(); + let task = app.update(Message::Request(Pending::Close(close))); + assert!(iced_runtime::task::into_stream(task).is_none()); + assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close)); + let _ = app.update(Message::Cancel); + save(&mut app, &second_path); + assert!(!app.dirty()); + assert_eq!(app.hosted_label(), "All changes saved"); + assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes()); + assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes()); + } +} + +#[test] +fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() { + let directory = tempfile::tempdir().unwrap(); + let path = directory + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + std::fs::write(&path, VALID).unwrap(); + let mut app = App::with_project(Project::empty()); + open(&mut app, &path); + let before = app.document.snapshot(); + let invalid = format!("{VALID} unfinished {{"); + std::fs::write(&path, &invalid).unwrap(); + open(&mut app, &path); + let repaired = format!("{VALID}// Repaired rationale\r\n"); + edit(&mut app, &repaired); + assert!(app.apply_source()); + assert!(app.dirty()); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + assert!( + app.dirty(), + "Old saved bytes are not saved at the repair destination" + ); + let _ = app.update(Message::Redo); + assert!(app.dirty()); + assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes()); + + std::fs::write(&path, "external edit").unwrap(); + let failed = write_snapshot( + app.canvas_document, + app.document.revision(), + app.document.snapshot(), + path.clone(), + app.disk_project.clone(), + ) + .unwrap_err(); + let _ = app.update(Message::Saved(Err(failed))); + assert!(app.dirty()); + assert_eq!(std::fs::read(&path).unwrap(), b"external edit"); + std::fs::write(&path, &invalid).unwrap(); + save(&mut app, &path); + assert!(!app.dirty()); + let _ = app.update(Message::Undo); + assert!(app.dirty()); + assert_eq!(app.document.bundle(), &before.bundle); + let _ = app.update(Message::Redo); + assert!(!app.dirty(), "Redo to the new saved snapshot is clean"); + assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes()); +} + +#[test] +fn valid_equal_content_at_distinct_destinations_stays_clean() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let second_path = second + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + std::fs::write(&first_path, VALID).unwrap(); + std::fs::write(&second_path, VALID).unwrap(); + let mut app = App::with_project(Project::empty()); + open(&mut app, &first_path); + let before = app.document.bundle().clone(); + open(&mut app, &second_path); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.path.as_ref(), Some(&second_path)); + assert!(!app.dirty()); + assert_eq!(app.hosted_label(), "All changes saved"); +} + +#[test] +fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let first_path = first.path().canonicalize().unwrap().join("project.arcadia"); + let root = second.path().canonicalize().unwrap(); + let path = root.join("project.arcadia"); + let before = VALID.replace("Repair 救援", "Before 救援"); + std::fs::write(&first_path, VALID).unwrap(); + std::fs::write(&path, VALID).unwrap(); + let journal = root.join(".arcadia-save-project.arcadia.journal"); + std::fs::create_dir(&journal).unwrap(); + std::fs::write( + journal.join("journal.json"), + br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#, + ) + .unwrap(); + std::fs::write(journal.join("0.before"), &before).unwrap(); + std::fs::write(journal.join("0.after"), VALID).unwrap(); + let mut app = App::with_project(Project::empty()); + open(&mut app, &first_path); + let accepted = app.document.snapshot(); + open(&mut app, &path); + assert_eq!(app.pending_recovery.as_ref(), Some(&path)); + assert!(!app.source_workspace.applied); + assert!(app.apply_source()); + assert!( + !app.dirty(), + "Current bytes still match disk before rollback" + ); + let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone()))); + assert!(app.pending_recovery.is_none()); + assert!(!journal.exists()); + assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes()); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.source_dirty()); + assert!(app.dirty(), "Recovery changed disk, not the accepted model"); + assert_eq!(app.hosted_label(), "Unsaved changes"); + save(&mut app, &path); + assert!(!app.dirty()); + assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes()); +} + +#[test] +fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() { + for (current_destination, failed_reload) in + [(true, false), (false, false), (true, true), (false, true)] + { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let path = root.join("project.arcadia"); + std::fs::write(&path, VALID).unwrap(); + let mut app = App::with_project(Project::empty()); + open(&mut app, &path); + let accepted = app.document.snapshot(); + let journal = root.join(".arcadia-save-project.arcadia.journal"); + std::fs::create_dir(&journal).unwrap(); + std::fs::write( + journal.join("journal.json"), + br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#, + ) + .unwrap(); + std::fs::write(journal.join("0.after"), VALID).unwrap(); + open(&mut app, &path); + assert!(app.apply_source()); + assert!(!app.dirty()); + let destination = tempfile::tempdir().unwrap(); + let saved_elsewhere = destination + .path() + .canonicalize() + .unwrap() + .join("project.arcadia"); + if !current_destination { + save(&mut app, &saved_elsewhere); + assert_eq!(app.path.as_ref(), Some(&saved_elsewhere)); + assert_eq!(app.pending_recovery.as_ref(), Some(&path)); + } + let mut receipt = recover_snapshot(path.clone()).unwrap(); + assert!( + receipt.disk.as_ref().unwrap().bundle.files.is_empty(), + "Missing entry reloads as an empty repair workspace" + ); + if failed_reload { + // Separately exercise the adapter's successful-recovery/failed-read + // boundary; first-save rollback itself does not cause a read error. + receipt.disk = Err("Injected post-recovery reload failure".into()); + } + let _ = app.update(Message::SourceRecovered(Ok(receipt))); + assert!(!path.exists()); + assert!(!journal.exists()); + assert!(app.pending_recovery.is_none()); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.dirty(), current_destination); + if current_destination { + let _ = app.update(Message::Request(Pending::New)); + assert!(matches!(app.pending, Some(Pending::New))); + } else { + assert_eq!(app.path.as_ref(), Some(&saved_elsewhere)); + assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes()); + } + } +} diff --git a/crates/oa-desktop/src/document.rs b/crates/oa-desktop/src/document.rs index 37c30df2..8369645d 100644 --- a/crates/oa-desktop/src/document.rs +++ b/crates/oa-desktop/src/document.rs @@ -95,7 +95,7 @@ pub struct Document { past: Vec, future: Vec, current_bytes: usize, - saved: Arc, + saved: Option>, revision: u64, view_index: std::cell::OnceCell, limits: ProjectLimits, @@ -125,7 +125,7 @@ impl Document { current_bytes, past: Vec::new(), future: Vec::new(), - saved: Arc::new(bundle), + saved: Some(Arc::new(bundle)), revision: 0, view_index: Default::default(), limits, @@ -156,7 +156,7 @@ impl Document { } pub fn dirty(&self) -> bool { - self.recovered_unsaved || self.bundle() != self.saved.as_ref() + self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle()) } /// Recovered work is a detached copy until explicitly saved by its user. @@ -181,10 +181,24 @@ impl Document { /// Records the bytes actually saved, even when later edits remain current. /// The I/O adapter owns document identity and save completion ordering. pub fn mark_saved(&mut self, snapshot: Arc) { - self.saved = Arc::new(snapshot.bundle.clone()); + self.saved = Some(Arc::new(snapshot.bundle.clone())); self.recovered_unsaved = false; } + /// Bind an installed or recovered repair destination to source read there. + /// These bytes may be invalid or differ from the retained accepted model, + /// but must replace the previous disk baseline. Accepted history is retained; + /// applying or undoing to old saved content does not save this destination. + pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) { + self.saved = Some(Arc::new(source)); + } + + /// Recovery changed the current destination, but its source could not be + /// reloaded. No accepted or historical snapshot is known to be saved there. + pub(crate) fn invalidate_saved_baseline(&mut self) { + self.saved = None; + } + /// Check a source Save as before writing any files. A retained included file /// must never be displaced by the new entry, including on a later Undo. pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {