authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 02:33:19-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:05:31-07:00
logba6f9efea7036fac3aaa6e862ce554464d106cd0
tree689c54cf9810cfa361f1b3272065f2e58e009e37
parent7e0a9db00914a038e28966175a528ab0e0edf06e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

2 files changed, 79 insertions(+), 12 deletions(-)

crates/snowbound/src/main.rs+3
......@@ -858,6 +858,8 @@ struct State {
858858 folder_rename: Option<(String, String)>,
859859 /// The notebook locations whose folders are on their way to the Trash.
860860 trashing: HashSet<String>,
861 /// The iCloud notebook locations a listing missed, waiting to be confirmed gone.
862 vanishing: HashSet<String>,
861863 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
862864 tags: Vec<canvas::editor::NoteTag>,
863865 /// The Customize Tags dialog's list while it is open.
......@@ -1325,6 +1327,7 @@ impl State {
13251327 icloud_reading: HashSet::new(),
13261328 folder_rename: None,
13271329 trashing: HashSet::new(),
1330 vanishing: HashSet::new(),
13281331 tags: stored
13291332 .tags
13301333 .unwrap_or_else(canvas::editor::NoteTag::defaults),
crates/snowbound/src/manage.rs+76-12
......@@ -71,6 +71,17 @@ pub fn in_icloud_folder(location: &str) -> bool {
7171 crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root))
7272}
7373
74/// How long a notebook folder a listing of the iCloud folder missed must stay missing to count
75/// as gone.
76const VANISH: std::time::Duration = std::time::Duration::from_secs(3);
77
78/// Whether the folder at `path` is still not there `after` it was missed: not found, rather
79/// than unreadable for a moment, as iCloud Drive's coordinated writes can leave it.
80fn vanished(path: &Path, after: std::time::Duration) -> bool {
81 std::thread::sleep(after);
82 notebook::fs::metadata(path).is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
83}
84
7485/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
7586/// or one inside a moved group.
7687fn follow(current: &str, from: &str, to: &str) -> String {
......@@ -300,21 +311,18 @@ impl State {
300311 /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in
301312 /// place of those the sidebar lists from it.
302313 fn listed_icloud(&mut self, listed: Vec<String>) {
303 let gone: Vec<Arc<Library>> = self
304 .notebooks
305 .iter()
306 .filter(|library| {
307 in_icloud_folder(&library.location)
308 && !listed.contains(&library.location)
309 && !self.folder_renaming(&library.location)
310 && !self.trashing.contains(&library.location)
311 // A listing taken before a rename names the folder by its old name.
312 && notebook::fs::metadata(&library.location).is_err()
314 let missed: Vec<String> = (self.notebooks.iter())
315 .map(|library| &library.location)
316 .filter(|location| {
317 in_icloud_folder(location)
318 && !listed.contains(location)
319 && !self.folder_renaming(location)
320 && !self.trashing.contains(*location)
313321 })
314322 .cloned()
315323 .collect();
316 for library in gone {
317 self.close_notebook(&library);
324 for location in missed {
325 self.confirm_gone(location);
318326 }
319327 for location in listed {
320328 if self.notebooks.iter().any(|open| open.location == location)
......@@ -348,6 +356,36 @@ impl State {
348356 }
349357 }
350358
359 /// Closes the notebook at `location`, which a listing of the iCloud folder missed, once
360 /// its folder is still not there `VANISH` later: iCloud Drive's own writes and downloads
361 /// can hide a folder from a listing for a moment. The notebook shown stays while edits
362 /// wait in it.
363 fn confirm_gone(&mut self, location: String) {
364 if !self.vanishing.insert(location.clone()) {
365 return;
366 }
367 let proxy = self.proxy.clone();
368 crate::spawn(move || {
369 let gone = vanished(Path::new(&location), VANISH);
370 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
371 state.vanishing.remove(&location);
372 let waiting = (state.session.as_ref())
373 .filter(|session| session.library.location == location)
374 .is_some_and(|session| {
375 session
376 .section
377 .pending()
378 .is_ok_and(|pending| !pending.is_empty())
379 });
380 let listed = (state.notebooks.iter()).find(|open| open.location == location);
381 if let Some(library) = listed.cloned().filter(|_| gone && !waiting) {
382 state.close_notebook(&library);
383 }
384 Ok(())
385 })));
386 });
387 }
388
351389 /// Whether a folder rename is taking the notebook from or to `location`.
352390 pub(crate) fn folder_renaming(&self, location: &str) -> bool {
353391 self.folder_rename
......@@ -1436,4 +1474,30 @@ mod tests {
14361474 }
14371475 notebook::fs::remove_dir_all(&temporary).unwrap();
14381476 }
1477
1478 /// A notebook folder that leaves the iCloud folder for a moment, as iCloud Drive's
1479 /// writes can take it, is not gone; one that stays away is.
1480 #[test]
1481 fn a_folder_missing_for_a_moment_is_not_gone() {
1482 let temporary =
1483 std::env::temp_dir().join(format!("snowbound-vanish-{}", std::process::id()));
1484 let (folder, away) = (temporary.join("Cloudy"), temporary.join(".Cloudy-away"));
1485 notebook::fs::create_dir_all(&folder).unwrap();
1486 notebook::fs::rename(&folder, &away).unwrap();
1487 let back = std::thread::spawn({
1488 let (folder, away) = (folder.clone(), away.clone());
1489 move || {
1490 std::thread::sleep(std::time::Duration::from_millis(100));
1491 notebook::fs::rename(away, folder).unwrap();
1492 }
1493 });
1494 assert!(!super::vanished(
1495 &folder,
1496 std::time::Duration::from_millis(600)
1497 ));
1498 back.join().unwrap();
1499 notebook::fs::remove_dir_all(&folder).unwrap();
1500 assert!(super::vanished(&folder, std::time::Duration::ZERO));
1501 notebook::fs::remove_dir_all(&temporary).unwrap();
1502 }
14391503}