From b0622d20cadd7e994a51ce2f48e415efbbdec24f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:09:21 -0400 Subject: [PATCH] Autosave: a recovery listing judges each key once, so an autosave in progress isn't listed twice (#9324) * Autosave: a recovery listing judges each key once, so an autosave in progress isn't listed twice * Autosave: the recovery listing builds for the browser too --- crates/format/src/autosave.rs | 55 +++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index cb09e27cb8..c5ba725edc 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -9,6 +9,7 @@ //! [`RecoveryStore`] is the app-facing lifecycle: an entry is only deleted once //! the document is saved or closed, never just because it was recovered. +use std::collections::BTreeSet; #[cfg(not(target_arch = "wasm32"))] use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -247,41 +248,51 @@ fn read_sidecar_bounded(path: &Path, max: usize) -> Option> { /// Recoverable documents in `recovery_dir`, newest first. A sidecar must be named after its own /// (sanitized) key, so a stray or hand-edited one can never point outside the directory. +/// +/// Each key is looked at once, after the directory has been read: an autosave may publish a +/// document's first sidecar while the listing runs, and judging the bundle and the sidecar as +/// each name came up listed that bundle twice, or not at all. pub fn list_recovery(recovery_dir: &Path) -> Vec { - let mut out = Vec::new(); let Ok(rd) = std::fs::read_dir(recovery_dir) else { - return out; + return Vec::new(); }; - for e in rd.flatten() { - let p = e.path(); - if e.file_type().is_ok_and(|ft| ft.is_file()) - && p.extension().is_some_and(|x| x == "json") - && let Some(bytes) = read_sidecar_bounded(&p, 1 << 20) - && let Ok(info) = serde_json::from_slice::(&bytes) - && sanitize(&info.key) == info.key - && p.file_stem().is_some_and(|stem| *stem == *info.key) - { - let bundle = recovery_dir.join(format!("{}.pcraft", info.key)); - if bundle.join(crate::store::MANIFEST).is_file() { - out.push(RecoveryEntry { info, bundle }); - } - } else if e.file_type().is_ok_and(|ft| ft.is_dir()) - && let Some(entry) = sidecarless_entry(recovery_dir, &p) - { - out.push(entry); - } - } + let keys: BTreeSet = rd + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + name.strip_suffix(".json").or_else(|| name.strip_suffix(".pcraft")).map(str::to_string) + }) + .collect(); + let mut out: Vec = keys.iter().filter_map(|key| recovery_entry(recovery_dir, key)).collect(); out.sort_by(|a, b| b.info.saved_at.cmp(&a.info.saved_at).then(a.info.key.cmp(&b.info.key))); out } +/// `key`'s entry. The sidecar is tried again last: it can appear after the first look, once the +/// bundle it describes is committed, and is never removed by a save. +fn recovery_entry(recovery_dir: &Path, key: &str) -> Option { + let bundle = recovery_dir.join(format!("{key}.pcraft")); + sidecar_entry(recovery_dir, key, &bundle).or_else(|| sidecarless_entry(recovery_dir, &bundle)).or_else(|| sidecar_entry(recovery_dir, key, &bundle)) +} + +fn sidecar_entry(recovery_dir: &Path, key: &str, bundle: &Path) -> Option { + let bytes = read_sidecar_bounded(&recovery_dir.join(format!("{key}.json")), 1 << 20)?; + let info = serde_json::from_slice::(&bytes).ok()?; + (sanitize(&info.key) == info.key && info.key == key && bundle.join(crate::store::MANIFEST).is_file()) + .then(|| RecoveryEntry { info, bundle: bundle.to_path_buf() }) +} + /// A committed `.pcraft` bundle (its manifest is written last) whose sidecar was never /// published, e.g. a crash during a document's first autosave. The metadata is rebuilt from the /// manifest (read like a load reads it) and its modification time. Incomplete bundles (no /// readable manifest) and bundles with anything at the sidecar's path are not entries. fn sidecarless_entry(recovery_dir: &Path, bundle: &Path) -> Option { let key = bundle.file_name()?.to_str()?.strip_suffix(".pcraft")?; - if key.is_empty() || sanitize(key) != key || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() { + if !std::fs::symlink_metadata(bundle).is_ok_and(|m| m.is_dir()) + || key.is_empty() + || sanitize(key) != key + || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() + { return None; } let meta = std::fs::symlink_metadata(bundle.join(crate::store::MANIFEST)).ok().filter(|m| m.is_file())?;