authorclover caruso <git@paperclover.net> 2026-10-10 21:29:14-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:06-07:00
logb28c7cbe0b6bd7420e717e2005265ecbea3a37de
tree322d97775b50afaf2dd6283bac6019c71b0659bf
parent1cec85d1bb8078889dab2f72485b2407ee2d2de3
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: deliver the refreshed recycle-bin catalog to the open notebook

Keep the completed recycle/unrecycle Notebook in Site.library and reconcile its catalog independently of page loading. Delivery follows the current section in the original open notebook lineage, leaving newer navigation and closed/reopened notebooks alone. Acquire structural ownership before retaining the replica, and enqueue catalog completion before releasing it, to avoid folder-rename lock inversion and older catalog delivery after a newer structure mutation. Extend the existing delete/Undo/Redo regression to check refreshed bin tabs without a watcher and unchanged replica ownership. Fixes #119 Assisted-by: gpt-6.1-sol

1 files changed, 77 insertions(+), 16 deletions(-)

crates/snowbound/src/undo.rs+77-16
...@@ -411,7 +411,7 @@ impl Site {...@@ -411,7 +411,7 @@ impl Site {
411 /// Does `change` to the section's pages, `shown` the page open; `None` where what it411 /// Does `change` to the section's pages, `shown` the page open; `None` where what it
412 /// changes is gone or holds what it did not make, which it leaves alone.412 /// changes is gone or holds what it did not make, which it leaves alone.
413 async fn change(413 async fn change(
414 &self,414 &mut self,
415 change: Change,415 change: Change,
416 shown: ExGuid,416 shown: ExGuid,
417 ) -> Result<Option<Applied>, Box<dyn Error>> {417 ) -> Result<Option<Applied>, Box<dyn Error>> {
...@@ -454,10 +454,9 @@ impl Site {...@@ -454,10 +454,9 @@ impl Site {
454 let (show, added) = self.after(&listed, &pages, show, &mut ops);454 let (show, added) = self.after(&listed, &pages, show, &mut ops);
455 if !created && binned {455 if !created && binned {
456 let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect();456 let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect();
457 self.library457 let mut notebook = self.library.reopen()?;
458 .reopen()?458 notebook.recycle_pages_async(&pages, &self.author).await?;
459 .recycle_pages_async(&pages, &self.author)459 self.library = Arc::new(self.library.with(notebook));
460 .await?;
461 self.replica.written_async().await?;460 self.replica.written_async().await?;
462 }461 }
463 self.apply(ops).await?;462 self.apply(ops).await?;
...@@ -508,10 +507,9 @@ impl Site {...@@ -508,10 +507,9 @@ impl Site {
508 if !created && binned {507 if !created && binned {
509 let identities: Vec<[u8; 16]> =508 let identities: Vec<[u8; 16]> =
510 back.iter().filter_map(|kept| kept.page.identity).collect();509 back.iter().filter_map(|kept| kept.page.identity).collect();
511 self.library510 let mut notebook = self.library.reopen()?;
512 .reopen()?511 notebook.unrecycle_pages_async(&identities).await?;
513 .unrecycle_pages_async(&identities)512 self.library = Arc::new(self.library.with(notebook));
514 .await?;
515 }513 }
516 Applied {514 Applied {
517 undo: Change::Delete {515 undo: Change::Delete {
...@@ -1062,13 +1060,29 @@ impl State {...@@ -1062,13 +1060,29 @@ impl State {
1062 };1060 };
1063 self.persist()?;1061 self.persist()?;
1064 let session = self.session.as_ref().ok_or("No section is open")?;1062 let session = self.session.as_ref().ok_or("No section is open")?;
1065 let site = Site {1063 let changing = if matches!(
1064 &change,
1065 Change::Delete { created: false, .. } | Change::Restore { created: false, .. }
1066 ) && matches!(session.library.notebook, Ok(Some(_)))
1067 {
1068 Some(
1069 session
1070 .library
1071 .structure
1072 .try_lock_arc()
1073 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::WouldBlock))?,
1074 )
1075 } else {
1076 None
1077 };
1078 let mut site = Site {
1066 replica: Arc::clone(session.section.replica()),1079 replica: Arc::clone(session.section.replica()),
1067 library: Arc::clone(&session.library),1080 library: Arc::clone(&session.library),
1068 author: self.author.clone(),1081 author: self.author.clone(),
1069 fresh: self.dated_page(None, "")?,1082 fresh: self.dated_page(None, "")?,
1070 };1083 };
1071 let shown = session.space;1084 let shown = session.space;
1085 let original = Arc::clone(&site.library);
1072 let proxy = self.proxy.clone();1086 let proxy = self.proxy.clone();
1073 self.undo.busy += 1;1087 self.undo.busy += 1;
1074 self.switching = Some((None, web_time::Instant::now()));1088 self.switching = Some((None, web_time::Instant::now()));
...@@ -1080,8 +1094,34 @@ impl State {...@@ -1080,8 +1094,34 @@ impl State {
1080 };1094 };
1081 let failed = applied.as_ref().err().map(ToString::to_string);1095 let failed = applied.as_ref().err().map(ToString::to_string);
1082 let (stale, applied) = (failed.is_none(), applied.ok().flatten());1096 let (stale, applied) = (failed.is_none(), applied.ok().flatten());
1097 let library = Arc::clone(&site.library);
1083 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {1098 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {
1084 state.undo.busy -= 1;1099 state.undo.busy -= 1;
1100 let lineage = |listed: &Library| {
1101 listed.location == original.location
1102 && Arc::ptr_eq(&listed.structure, &original.structure)
1103 };
1104 if !Arc::ptr_eq(&original, &library)
1105 && state.notebooks.iter().any(|listed| lineage(listed))
1106 {
1107 let path = state
1108 .session
1109 .as_ref()
1110 .filter(|session| lineage(&session.library))
1111 .map(|session| session.tabs[session.tab].path.clone());
1112 if let Some(path) = path {
1113 state.adopt(Arc::clone(&library), Some(&path))?;
1114 } else {
1115 state.list(&library);
1116 if state
1117 .sectionless
1118 .as_ref()
1119 .is_some_and(|listed| lineage(listed))
1120 {
1121 state.sectionless = Some(library);
1122 }
1123 }
1124 }
1085 let Some(applied) = applied else {1125 let Some(applied) = applied else {
1086 // What it would change is gone: Undo goes on to the step before.1126 // What it would change is gone: Undo goes on to the step before.
1087 return match taken {1127 return match taken {
...@@ -1102,6 +1142,7 @@ impl State {...@@ -1102,6 +1142,7 @@ impl State {
1102 state.edited(Vec::new());1142 state.edited(Vec::new());
1103 Ok(())1143 Ok(())
1104 })));1144 })));
1145 drop(changing);
1105 if let Some(failed) = failed {1146 if let Some(failed) = failed {
1106 return Err(failed.into());1147 return Err(failed.into());
1107 }1148 }
...@@ -1426,21 +1467,41 @@ mod tests {...@@ -1426,21 +1467,41 @@ mod tests {
1426 fn deleted_pages_come_back_where_they_were_and_leave_the_bin() {1467 fn deleted_pages_come_back_where_they_were_and_leave_the_bin() {
1427 let fixture = Fixture::new("delete", &["First", "Second", "Third"]);1468 let fixture = Fixture::new("delete", &["First", "Second", "Third"]);
1428 let second = fixture.space("Second");1469 let second = fixture.space("Second");
1429 let deleted = fixture1470 let mut site = fixture.site();
1430 .change(Change::Delete {1471 assert!(site.library.tabs(crate::recycle::BIN).is_empty());
1472 let deleted = pollster::block_on(site.change(
1473 Change::Delete {
1431 pages: vec![second],1474 pages: vec![second],
1432 show: None,1475 show: None,
1433 created: false,1476 created: false,
1434 })1477 },
1435 .unwrap();1478 fixture.space("First"),
1479 ))
1480 .unwrap()
1481 .unwrap();
1482 assert!(!site.library.tabs(crate::recycle::BIN).is_empty());
1483 assert!(!Arc::ptr_eq(&site.library, &fixture.library));
1484 assert!(Arc::ptr_eq(
1485 &site.library.structure,
1486 &fixture.library.structure
1487 ));
1488 assert!(Arc::ptr_eq(&site.replica, fixture.section().replica()));
1489 assert!(fixture.library.tabs(crate::recycle::BIN).is_empty());
1490 let deleted_library = Arc::clone(&site.library);
1436 assert_eq!(fixture.titles(), titled(&["First", "Third"]));1491 assert_eq!(fixture.titles(), titled(&["First", "Third"]));
1437 assert_eq!(deleted.show, fixture.space("Third"));1492 assert_eq!(deleted.show, fixture.space("Third"));
1438 assert_eq!(fixture.binned(), ["Second"]);1493 assert_eq!(fixture.binned(), ["Second"]);
1439 let restored = fixture.change(deleted.undo).unwrap();1494 let restored = pollster::block_on(site.change(deleted.undo, deleted.show))
1495 .unwrap()
1496 .unwrap();
1497 assert!(!Arc::ptr_eq(&site.library, &deleted_library));
1498 assert!(!site.library.tabs(crate::recycle::BIN).is_empty());
1440 assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"]));1499 assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"]));
1441 assert_eq!(restored.show, fixture.space("Second"));1500 assert_eq!(restored.show, fixture.space("Second"));
1442 assert!(fixture.binned().is_empty());1501 assert!(fixture.binned().is_empty());
1443 let again = fixture.change(restored.undo).unwrap();1502 let again = pollster::block_on(site.change(restored.undo, restored.show))
1503 .unwrap()
1504 .unwrap();
1444 assert_eq!(fixture.titles(), titled(&["First", "Third"]));1505 assert_eq!(fixture.titles(), titled(&["First", "Third"]));
1445 assert_eq!(fixture.binned(), ["Second"]);1506 assert_eq!(fixture.binned(), ["Second"]);
14461507