From ba6f9efea7036fac3aaa6e862ce554464d106cd0 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 3 Oct 2026 02:33:19 -0700 Subject: [PATCH] fix: an iCloud notebook a listing misses closes only once its folder stays gone iCloud Drive's own writes and downloads can hide a notebook's folder from a listing of the app's iCloud folder for a moment, which closed the notebook being written in and listed it again a second later. A folder a listing misses now closes its notebook only when it is still not found three seconds on, never while the notebook shown has edits waiting. Delete Notebook still takes its notebook away at once. Assisted-by: claude-opus-5.5 --- crates/snowbound/src/main.rs | 3 ++ crates/snowbound/src/manage.rs | 88 +++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index fc0848e8ea777829707cef935e64f8b7cb6f9842..612cebd400d2ab5d93965b3967aa59428b974eff 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -858,6 +858,8 @@ struct State { folder_rename: Option<(String, String)>, /// The notebook locations whose folders are on their way to the Trash. trashing: HashSet, + /// The iCloud notebook locations a listing missed, waiting to be confirmed gone. + vanishing: HashSet, /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. tags: Vec, /// The Customize Tags dialog's list while it is open. @@ -1325,6 +1327,7 @@ impl State { icloud_reading: HashSet::new(), folder_rename: None, trashing: HashSet::new(), + vanishing: HashSet::new(), tags: stored .tags .unwrap_or_else(canvas::editor::NoteTag::defaults), diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 241cff0123433126b44538b77bb9f35670554192..10a25c0fdfc4a609ba03888b56444a31fa164946 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -71,6 +71,17 @@ pub fn in_icloud_folder(location: &str) -> bool { crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root)) } +/// How long a notebook folder a listing of the iCloud folder missed must stay missing to count +/// as gone. +const VANISH: std::time::Duration = std::time::Duration::from_secs(3); + +/// Whether the folder at `path` is still not there `after` it was missed: not found, rather +/// than unreadable for a moment, as iCloud Drive's coordinated writes can leave it. +fn vanished(path: &Path, after: std::time::Duration) -> bool { + std::thread::sleep(after); + notebook::fs::metadata(path).is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) +} + /// `current`, the path of a section, after `from` moved to `to`: a section moved itself, /// or one inside a moved group. fn follow(current: &str, from: &str, to: &str) -> String { @@ -300,21 +311,18 @@ impl State { /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in /// place of those the sidebar lists from it. fn listed_icloud(&mut self, listed: Vec) { - let gone: Vec> = self - .notebooks - .iter() - .filter(|library| { - in_icloud_folder(&library.location) - && !listed.contains(&library.location) - && !self.folder_renaming(&library.location) - && !self.trashing.contains(&library.location) - // A listing taken before a rename names the folder by its old name. - && notebook::fs::metadata(&library.location).is_err() + let missed: Vec = (self.notebooks.iter()) + .map(|library| &library.location) + .filter(|location| { + in_icloud_folder(location) + && !listed.contains(location) + && !self.folder_renaming(location) + && !self.trashing.contains(*location) }) .cloned() .collect(); - for library in gone { - self.close_notebook(&library); + for location in missed { + self.confirm_gone(location); } for location in listed { if self.notebooks.iter().any(|open| open.location == location) @@ -348,6 +356,36 @@ impl State { } } + /// Closes the notebook at `location`, which a listing of the iCloud folder missed, once + /// its folder is still not there `VANISH` later: iCloud Drive's own writes and downloads + /// can hide a folder from a listing for a moment. The notebook shown stays while edits + /// wait in it. + fn confirm_gone(&mut self, location: String) { + if !self.vanishing.insert(location.clone()) { + return; + } + let proxy = self.proxy.clone(); + crate::spawn(move || { + let gone = vanished(Path::new(&location), VANISH); + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + state.vanishing.remove(&location); + let waiting = (state.session.as_ref()) + .filter(|session| session.library.location == location) + .is_some_and(|session| { + session + .section + .pending() + .is_ok_and(|pending| !pending.is_empty()) + }); + let listed = (state.notebooks.iter()).find(|open| open.location == location); + if let Some(library) = listed.cloned().filter(|_| gone && !waiting) { + state.close_notebook(&library); + } + Ok(()) + }))); + }); + } + /// Whether a folder rename is taking the notebook from or to `location`. pub(crate) fn folder_renaming(&self, location: &str) -> bool { self.folder_rename @@ -1436,4 +1474,30 @@ mod tests { } notebook::fs::remove_dir_all(&temporary).unwrap(); } + + /// A notebook folder that leaves the iCloud folder for a moment, as iCloud Drive's + /// writes can take it, is not gone; one that stays away is. + #[test] + fn a_folder_missing_for_a_moment_is_not_gone() { + let temporary = + std::env::temp_dir().join(format!("snowbound-vanish-{}", std::process::id())); + let (folder, away) = (temporary.join("Cloudy"), temporary.join(".Cloudy-away")); + notebook::fs::create_dir_all(&folder).unwrap(); + notebook::fs::rename(&folder, &away).unwrap(); + let back = std::thread::spawn({ + let (folder, away) = (folder.clone(), away.clone()); + move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + notebook::fs::rename(away, folder).unwrap(); + } + }); + assert!(!super::vanished( + &folder, + std::time::Duration::from_millis(600) + )); + back.join().unwrap(); + notebook::fs::remove_dir_all(&folder).unwrap(); + assert!(super::vanished(&folder, std::time::Duration::ZERO)); + notebook::fs::remove_dir_all(&temporary).unwrap(); + } } -- 2.54.0