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 {...@@ -858,6 +858,8 @@ struct State {
858 folder_rename: Option<(String, String)>,858 folder_rename: Option<(String, String)>,
859 /// The notebook locations whose folders are on their way to the Trash.859 /// The notebook locations whose folders are on their way to the Trash.
860 trashing: HashSet<String>,860 trashing: HashSet<String>,
861 /// The iCloud notebook locations a listing missed, waiting to be confirmed gone.
862 vanishing: HashSet<String>,
861 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.863 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
862 tags: Vec<canvas::editor::NoteTag>,864 tags: Vec<canvas::editor::NoteTag>,
863 /// The Customize Tags dialog's list while it is open.865 /// The Customize Tags dialog's list while it is open.
...@@ -1325,6 +1327,7 @@ impl State {...@@ -1325,6 +1327,7 @@ impl State {
1325 icloud_reading: HashSet::new(),1327 icloud_reading: HashSet::new(),
1326 folder_rename: None,1328 folder_rename: None,
1327 trashing: HashSet::new(),1329 trashing: HashSet::new(),
1330 vanishing: HashSet::new(),
1328 tags: stored1331 tags: stored
1329 .tags1332 .tags
1330 .unwrap_or_else(canvas::editor::NoteTag::defaults),1333 .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 {...@@ -71,6 +71,17 @@ pub fn in_icloud_folder(location: &str) -> bool {
71 crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root))71 crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root))
72}72}
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
74/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,85/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
75/// or one inside a moved group.86/// or one inside a moved group.
76fn follow(current: &str, from: &str, to: &str) -> String {87fn follow(current: &str, from: &str, to: &str) -> String {
...@@ -300,21 +311,18 @@ impl State {...@@ -300,21 +311,18 @@ impl State {
300 /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in311 /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in
301 /// place of those the sidebar lists from it.312 /// place of those the sidebar lists from it.
302 fn listed_icloud(&mut self, listed: Vec<String>) {313 fn listed_icloud(&mut self, listed: Vec<String>) {
303 let gone: Vec<Arc<Library>> = self314 let missed: Vec<String> = (self.notebooks.iter())
304 .notebooks315 .map(|library| &library.location)
305 .iter()316 .filter(|location| {
306 .filter(|library| {317 in_icloud_folder(location)
307 in_icloud_folder(&library.location)318 && !listed.contains(location)
308 && !listed.contains(&library.location)319 && !self.folder_renaming(location)
309 && !self.folder_renaming(&library.location)320 && !self.trashing.contains(*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()
313 })321 })
314 .cloned()322 .cloned()
315 .collect();323 .collect();
316 for library in gone {324 for location in missed {
317 self.close_notebook(&library);325 self.confirm_gone(location);
318 }326 }
319 for location in listed {327 for location in listed {
320 if self.notebooks.iter().any(|open| open.location == location)328 if self.notebooks.iter().any(|open| open.location == location)
...@@ -348,6 +356,36 @@ impl State {...@@ -348,6 +356,36 @@ impl State {
348 }356 }
349 }357 }
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
351 /// Whether a folder rename is taking the notebook from or to `location`.389 /// Whether a folder rename is taking the notebook from or to `location`.
352 pub(crate) fn folder_renaming(&self, location: &str) -> bool {390 pub(crate) fn folder_renaming(&self, location: &str) -> bool {
353 self.folder_rename391 self.folder_rename
...@@ -1436,4 +1474,30 @@ mod tests {...@@ -1436,4 +1474,30 @@ mod tests {
1436 }1474 }
1437 notebook::fs::remove_dir_all(&temporary).unwrap();1475 notebook::fs::remove_dir_all(&temporary).unwrap();
1438 }1476 }
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 }
1439}1503}