mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
Wake idle windows for pending recovery snapshots (#2663)
This commit is contained in:
@@ -4666,6 +4666,11 @@ impl Session {
|
||||
write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
}
|
||||
|
||||
/// Whether a changed document still needs a recovery snapshot. This does not consume it.
|
||||
pub fn has_pending_autosave(&self) -> bool {
|
||||
self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation)
|
||||
}
|
||||
|
||||
/// Documents with unsaved changes made since the last call: their current working file,
|
||||
/// for crash recovery. Encrypted documents stay encrypted in the snapshot.
|
||||
pub fn autosave_snapshots(&mut self) -> Vec<RecoverySnapshot> {
|
||||
|
||||
@@ -1233,9 +1233,13 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() {
|
||||
#[test]
|
||||
fn autosave_snapshots_only_changed_documents() {
|
||||
let (mut s, id) = session_with(2);
|
||||
assert!(!s.has_pending_autosave());
|
||||
assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted");
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
assert!(s.has_pending_autosave());
|
||||
assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot");
|
||||
let snaps = s.autosave_snapshots();
|
||||
assert!(!s.has_pending_autosave());
|
||||
assert_eq!(snaps.len(), 1);
|
||||
assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file");
|
||||
assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot");
|
||||
|
||||
@@ -3028,7 +3028,7 @@ impl eframe::App for PdfCraftApp {
|
||||
// macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136).
|
||||
craft_quit::on_close_request(ctx);
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.autosave_tick(now);
|
||||
self.autosave_tick(ctx, now);
|
||||
// Reload documents whose files a build rewrote (#431).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.watch_files();
|
||||
|
||||
@@ -290,7 +290,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
/// Autosave when the interval has passed (called every frame).
|
||||
pub(crate) fn autosave_tick(&mut self, now: f64) {
|
||||
pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) {
|
||||
if self.recovery.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -298,6 +298,14 @@ impl PdfCraftApp {
|
||||
self.last_autosave = now;
|
||||
self.autosave_now();
|
||||
}
|
||||
// An idle native window has no input-driven frames. Wake it at the existing
|
||||
// autosave deadline, without keeping clean or already-snapshotted documents awake.
|
||||
if self.session.has_pending_autosave() {
|
||||
let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS);
|
||||
if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) {
|
||||
ctx.request_repaint_after(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopen recovered documents (`keys`), as unsaved changes at their original paths.
|
||||
@@ -385,3 +393,86 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod idle_tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new() -> Self {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration {
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
|
||||
app.autosave_tick(ui.ctx(), now);
|
||||
});
|
||||
// Headless timer test: acknowledge texture uploads without a GPU backend.
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output[&egui::ViewportId::ROOT].repaint_delay
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_dirty_document_requests_its_deadline_and_writes_without_input() {
|
||||
let dir = TestDir::new();
|
||||
let store = RecoveryStore::new(dir.path());
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.enable_recovery(store.clone());
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
let id = app.session.open_new("Untitled.pdf", bytes).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut delay = Duration::ZERO;
|
||||
for _ in 0..4 {
|
||||
delay = frame(&mut app, &ctx, 15.0);
|
||||
}
|
||||
assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}");
|
||||
assert!(store.list().is_empty());
|
||||
frame(&mut app, &ctx, 60.0);
|
||||
assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input");
|
||||
assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved");
|
||||
assert!(!app.session.has_pending_autosave());
|
||||
for _ in 0..4 {
|
||||
delay = frame(&mut app, &ctx, 61.0);
|
||||
}
|
||||
assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() {
|
||||
for recovery_enabled in [false, true] {
|
||||
let dir = TestDir::new();
|
||||
let mut app = PdfCraftApp::new();
|
||||
if recovery_enabled {
|
||||
app.enable_recovery(RecoveryStore::new(dir.path()));
|
||||
}
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
if recovery_enabled {
|
||||
app.session.open("Clean.pdf", None, bytes, None).unwrap();
|
||||
} else {
|
||||
app.session.open_new("Unsaved.pdf", bytes).unwrap();
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut delay = Duration::ZERO;
|
||||
for _ in 0..4 {
|
||||
delay = frame(&mut app, &ctx, 15.0);
|
||||
}
|
||||
assert_eq!(delay, Duration::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ live in `plan/execution-plan.md` (local-only, gitignored).
|
||||
Keep it current: update the milestone row and the [ROADMAP.md](../ROADMAP.md) progress log at the
|
||||
end of every session; re-estimate when a milestone lands.
|
||||
|
||||
Recovery scheduling (#2663): a native window with a pending dirty snapshot requests a
|
||||
repaint at the existing 60-second autosave deadline, even with no mouse or keyboard input.
|
||||
Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents,
|
||||
encryption and storage-write retry behavior are unchanged; this does not resolve #1803.
|
||||
|
||||
## Alpha gate
|
||||
|
||||
The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the
|
||||
|
||||
Reference in New Issue
Block a user