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(); + } }