authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:23:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:05:30-07:00
log75c421209dfa9de6f3d15319559844f50ece7cc2
treed92b0ea6b5a17609c7f82bda41d83ccf9c7da580
parent91ad7c52e4867559ba209810469eea16612dcca8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Delete Notebook moves a notebook's folder to the Trash

A notebook at the top of the iCloud folder can't be closed, as the folder would list it again, so its menu and ⌘K actions offer Delete Notebook, as a local notebook's do; a server's does not. It asks first, telling of changes not yet in its files, then closes the notebook and moves its folder to the Trash (the Recycle Bin on Windows, GIO's trash on Linux), where it can be put back; in iCloud Drive, on every device. Its replicas and queued edits, display name, recent places, unread state and fold state are forgotten, and the iCloud listing can't bring it back meanwhile. Refused, it opens again. Assisted-by: claude-opus-5.5

14 files changed, 294 insertions(+), 4 deletions(-)

crates/notebook/README.md+2-1
......@@ -55,7 +55,8 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k
5555`session::Section::open(file, cache_dir, notify)` opens one section file through
5656a replica named by the file's location and the section's document identity, so the same
5757file reopens the same queue after a relaunch and a copy of it elsewhere has its own
58(`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves).
58(`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves,
59and `forget` for one it deletes).
5960`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already
6061named by the identity its discovery read resumes without reading the file, which the
6162worker checks next, as on a share.
crates/notebook/src/location.rs+21
......@@ -62,6 +62,27 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
6262 Ok(())
6363}
6464
65/// Deletes what the cache keeps of the notebook at `location`, as when the app deletes the
66/// notebook: its replicas, edits waiting in them included, its sections' copies' and its
67/// listing. Every replica must be closed.
68pub fn forget(cache: &Path, location: &str) -> io::Result<()> {
69 let within = format!("{location}/");
70 if let Ok(entries) = fs::read_dir(cache.join("replicas")) {
71 for entry in entries {
72 let folder = entry?.path();
73 let ours = fs::read_to_string(folder.join(LOCATION))
74 .is_ok_and(|label| label == location || label.starts_with(&within));
75 if ours {
76 fs::remove_dir_all(&folder)?;
77 }
78 }
79 }
80 match fs::remove_file(crate::session::listing(cache, location)) {
81 Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
82 _ => Ok(()),
83 }
84}
85
6586/// The folder for `location`, made and labelled with it where it was not.
6687fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {
6788 let folder = folder(cache, location);
crates/notebook/src/session.rs+1-1
......@@ -1854,7 +1854,7 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {
18541854}
18551855
18561856/// Where the cache keeps what reading the catalog of the notebook at `location` took.
1857fn listing(cache: &Path, location: &str) -> PathBuf {
1857pub(crate) fn listing(cache: &Path, location: &str) -> PathBuf {
18581858 let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16]
18591859 .iter()
18601860 .map(|byte| format!("{byte:02x}"))
crates/notebook/tests/location.rs+18
......@@ -348,3 +348,21 @@ fn renaming_reordering_or_deleting_a_copy_leaves_the_originals_toc_entry() {
348348 assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original);
349349 assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original);
350350}
351
352#[test]
353fn forgetting_a_deleted_notebook_drops_its_replicas_and_queue_alone() {
354 let directory = tempfile::tempdir().unwrap();
355 let cache = directory.path().join("cache");
356 let root = notebook(directory.path(), "Notebook");
357 let other = notebook(directory.path(), "Other");
358 queued(&root, &cache, "Queued ");
359 queued(&other, &cache, "Kept ");
360 let location = notebook::location::local(&root).unwrap();
361 std::fs::remove_dir_all(&root).unwrap();
362 notebook::location::forget(&cache, &location).unwrap();
363 assert!(std::fs::metadata(notebook::location::folder(&cache, &location)).is_err());
364 // A notebook made where it was takes nothing of the queue; another keeps its own.
365 notebook(directory.path(), "Notebook");
366 assert_eq!(published(&root, &cache), 0);
367 assert_eq!(published(&other, &cache), 1);
368}
crates/snowbound/src/library.rs+27
......@@ -573,6 +573,33 @@ impl Library {
573573 Ok(to)
574574 }
575575
576 /// Moves the notebook's folder to the Trash once nothing of this notebook holds its files,
577 /// its background stopped and its kept sections closed as the open section must be too,
578 /// then forgets what this computer keeps of it: its replicas, edits waiting in them
579 /// included, and its display name. Returns why not, as its reader is told.
580 #[cfg(not(target_arch = "wasm32"))]
581 pub fn trash(&self) -> Result<(), String> {
582 let folder = self
583 .folder()
584 .ok_or("This notebook’s folder can’t be moved to the Trash.")?;
585 if let Some(background) = &self.background {
586 background.stop();
587 }
588 self.close_kept();
589 // As its replicas' folders name it.
590 let replicas = notebook::location::local(folder).map_err(|error| error.to_string())?;
591 crate::platform::trash(folder)?;
592 let forgotten = notebook::location::forget(&self.cache, &replicas).and_then(|()| {
593 edit_display_names(&self.cache, |names| {
594 names.remove(&self.location);
595 })
596 });
597 if let Err(error) = forgotten {
598 eprintln!("{}: {error}", self.location);
599 }
600 Ok(())
601 }
602
576603 /// The art the notebook's tags draw with.
577604 pub fn tag_art(&self) -> Arc<TagArt> {
578605 Arc::clone(
crates/snowbound/src/linux.rs+17
......@@ -1231,8 +1231,25 @@ fn innermost(value: OwnedValue) -> Option<OwnedValue> {
12311231 value.try_into_owned().ok()
12321232}
12331233
1234/// Where deleted files go, as the file manager names it.
1235pub const TRASH: &str = "Trash";
1236
12341237pub const SHOW_FILE: &str = "Show in Files";
12351238
1239/// Moves the file or folder at `path` to the desktop's trash, through GIO, as the file
1240/// manager's Move to Trash does.
1241pub fn trash(path: &std::path::Path) -> Result<(), String> {
1242 let output = std::process::Command::new("gio")
1243 .arg("trash")
1244 .arg(path)
1245 .output()
1246 .map_err(|error| format!("Couldn’t run gio to move it to the trash: {error}"))?;
1247 if output.status.success() {
1248 return Ok(());
1249 }
1250 Err(String::from_utf8_lossy(&output.stderr).trim().to_owned())
1251}
1252
12361253/// Selects `file` in the desktop's file manager, or opens its folder where none answers
12371254/// `org.freedesktop.FileManager1`.
12381255pub fn show_file(file: &std::path::Path) {
crates/snowbound/src/macos.rs+21
......@@ -512,6 +512,9 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::Resize
512512 None
513513}
514514
515/// Where deleted files go, as the file manager names it.
516pub const TRASH: &str = "Trash";
517
515518pub const SHOW_FILE: &str = "Show in Finder";
516519
517520/// Opens a Finder window with `file` selected.
......@@ -1619,6 +1622,24 @@ pub fn update_menu(statuses: impl FnOnce() -> Vec<commands::Status>) {
16191622 STATUSES.set(statuses());
16201623}
16211624
1625/// Moves the file or folder at `path` to the Trash, as the Finder's Move to Trash does, where
1626/// Put Back restores it; in iCloud Drive, to the Trash of every device signed in to it.
1627/// Refused before 10.8, whose file manager can't.
1628pub fn trash(path: &std::path::Path) -> Result<(), String> {
1629 let manager = unsafe { objc2_foundation::NSFileManager::defaultManager() };
1630 let can: bool = unsafe {
1631 msg_send![&manager, respondsToSelector: sel!(trashItemAtURL:resultingItemURL:error:)]
1632 };
1633 if !can {
1634 return Err("This version of macOS can’t move folders to the Trash.".into());
1635 }
1636 let url = unsafe {
1637 objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&path.to_string_lossy()))
1638 };
1639 unsafe { manager.trashItemAtURL_resultingItemURL_error(&url, None) }
1640 .map_err(|error| error.localizedDescription().to_string())
1641}
1642
16221643/// Opens `target`, a folder or a link's URL, as Finder would.
16231644pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
16241645 let target = target.as_ref();
crates/snowbound/src/main.rs+3
......@@ -856,6 +856,8 @@ struct State {
856856 icloud_reading: HashSet<String>,
857857 /// The notebook locations a folder rename takes a notebook from and to, while it does.
858858 folder_rename: Option<(String, String)>,
859 /// The notebook locations whose folders are on their way to the Trash.
860 trashing: HashSet<String>,
859861 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
860862 tags: Vec<canvas::editor::NoteTag>,
861863 /// The Customize Tags dialog's list while it is open.
......@@ -1322,6 +1324,7 @@ impl State {
13221324 server: None,
13231325 icloud_reading: HashSet::new(),
13241326 folder_rename: None,
1327 trashing: HashSet::new(),
13251328 tags: stored
13261329 .tags
13271330 .unwrap_or_else(canvas::editor::NoteTag::defaults),
crates/snowbound/src/manage.rs+80
......@@ -307,6 +307,7 @@ impl State {
307307 in_icloud_folder(&library.location)
308308 && !listed.contains(&library.location)
309309 && !self.folder_renaming(&library.location)
310 && !self.trashing.contains(&library.location)
310311 // A listing taken before a rename names the folder by its old name.
311312 && notebook::fs::metadata(&library.location).is_err()
312313 })
......@@ -318,6 +319,7 @@ impl State {
318319 for location in listed {
319320 if self.notebooks.iter().any(|open| open.location == location)
320321 || self.folder_renaming(&location)
322 || self.trashing.contains(&location)
321323 || !self.icloud_reading.insert(location.clone())
322324 {
323325 continue;
......@@ -331,6 +333,8 @@ impl State {
331333 if gone
332334 || state.notebooks.iter().any(|open| open.location == location)
333335 || state.folder_renaming(&location)
336 || state.trashing.contains(&location)
337 || notebook::fs::metadata(&location).is_err()
334338 {
335339 return Ok(());
336340 }
......@@ -535,6 +539,82 @@ impl State {
535539 self.save_settings();
536540 }
537541
542 /// Asks whether to move `library`'s folder to the Trash, telling of changes not yet in its
543 /// files, which go with it, then does.
544 #[cfg(not(target_arch = "wasm32"))]
545 pub(crate) fn delete_notebook(&mut self, library: Arc<Library>) {
546 // What was typed last counts too.
547 self.persist().unwrap_or_else(|error| eprintln!("{error}"));
548 let open = (self.session.as_ref())
549 .filter(|session| Arc::ptr_eq(&session.library, &library))
550 .and_then(|session| session.section.pending().ok())
551 .map_or(0, |pending| pending.len() as u64);
552 let waiting = (library.background.iter())
553 .flat_map(|background| background.status())
554 .map(|(_, sync)| sync.queued)
555 .sum::<u64>()
556 .max(open);
557 let mut detail = if in_icloud_folder(&library.location) {
558 "It moves to the Trash on every device signed in to this iCloud account.".to_owned()
559 } else {
560 format!("It moves to the {}.", platform::TRASH)
561 };
562 if waiting > 0 {
563 let changes = match waiting {
564 1 => "1 change".to_owned(),
565 count => format!("{count} changes"),
566 };
567 detail += &format!(" {changes} not yet saved to its files will be lost.");
568 }
569 platform::confirm(
570 &format!("Delete “{}”?", library.name),
571 &detail,
572 "Cancel",
573 "Delete",
574 self.reply(move |state, ()| {
575 state.trash_notebook(library);
576 Ok(())
577 }),
578 );
579 }
580
581 /// Closes `library` and moves its folder to the Trash on a thread of its own, forgetting
582 /// what this computer and the next launch keep of it. Refused, it opens again.
583 #[cfg(not(target_arch = "wasm32"))]
584 fn trash_notebook(&mut self, library: Arc<Library>) {
585 let location = library.location.clone();
586 if let Some(session) =
587 (self.session).take_if(|session| Arc::ptr_eq(&session.library, &library))
588 {
589 // Its replica goes with it, so nothing may hold it.
590 let _ = self.view.editor.take_ops();
591 if let Err(error) = session.section.close() {
592 eprintln!("{location}: {error}");
593 }
594 self.sectionless = Some(Arc::clone(&library));
595 }
596 self.trashing.insert(location.clone());
597 self.close_notebook(&library);
598 self.trail.forget(&location);
599 self.reads.forget(&location);
600 let ours = crate::library::key(&location, "");
601 self.folded.retain(|key| !key.starts_with(&ours));
602 self.last_pages.retain(|key, _| !key.starts_with(&ours));
603 self.save_settings();
604 let proxy = self.proxy.clone();
605 crate::spawn(move || {
606 let trashed = library.trash();
607 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
608 state.trashing.remove(&location);
609 if let Err(problem) = trashed {
610 platform::alert("Couldn’t delete the notebook", &problem);
611 state.open_notebook(location, None);
612 }
613 Ok(())
614 })));
615 });
616 }
617
538618 /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed
539619 /// meanwhile, then opens it from there in its place, at the section it showed, with `color`
540620 /// given it. What this computer keeps by the notebook's location follows it. Refused, the
crates/snowbound/src/menus.rs+18-2
......@@ -549,7 +549,7 @@ impl State {
549549 let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library));
550550 let last = self.notebooks.len().saturating_sub(1);
551551 let unread = self.unread_notebook(library);
552 vec![
552 let mut actions = vec![
553553 item(Action::Rename, "Rename", false, false),
554554 item(
555555 Action::SaveAs,
......@@ -570,6 +570,16 @@ impl State {
570570 crate::manage::in_icloud_folder(&library.location),
571571 false,
572572 ),
573 ];
574 // Its folder to the Trash, as the Finder deletes it; a server's stays there.
575 if cfg!(not(target_arch = "wasm32"))
576 && library.folder().is_some()
577 && !library.on_smb()
578 && !matches!(library.notebook, Ok(None))
579 {
580 actions.push(item(Action::Delete, "Delete Notebook", false, false));
581 }
582 actions.extend([
573583 item(Action::CopyLink, "Copy Link to Notebook", false, true),
574584 #[cfg(feature = "live")]
575585 item(
......@@ -627,7 +637,8 @@ impl State {
627637 false,
628638 ),
629639 styles(library),
630 ]
640 ]);
641 actions
631642 }
632643 Target::Closed(_) | Target::Server(_) => {
633644 vec![item(Action::Delete, "Remove from Recent", false, false)]
......@@ -931,6 +942,11 @@ impl State {
931942 None
932943 }
933944 (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)),
945 #[cfg(not(target_arch = "wasm32"))]
946 (Target::Notebook(library), Action::Delete) => {
947 self.delete_notebook(library);
948 None
949 }
934950 #[cfg(feature = "live")]
935951 (Target::Notebook(library), Action::LiveShare) => {
936952 self.open_live_share(library);
crates/snowbound/src/navigation.rs+9
......@@ -67,6 +67,15 @@ impl Trail {
6767 }
6868 }
6969
70 /// Forgets every place in the notebook at `location`, which is gone.
71 pub fn forget(&mut self, location: &str) {
72 let elsewhere = |visit: &Visit| visit.notebook != location;
73 self.back.retain(elsewhere);
74 self.forward.retain(elsewhere);
75 self.here = self.here.take().filter(elsewhere);
76 self.recent.retain(|place| place.notebook != location);
77 }
78
7079 /// Notes `place` shown, as the recent pages list it.
7180 pub fn remember(&mut self, place: Place) {
7281 self.recent.retain(|recent| *recent != place);
crates/snowbound/src/unread.rs+8
......@@ -99,6 +99,14 @@ impl Reads {
9999 }
100100 }
101101
102 /// Forgets what was read in the notebook at `notebook`, which is gone.
103 pub fn forget(&mut self, notebook: &str) {
104 self.viewing = (self.viewing.take()).filter(|(viewing, ..)| viewing != notebook);
105 if self.notebooks.remove(notebook).is_some() {
106 self.save();
107 }
108 }
109
102110 /// Whether Show Unread Changes is on for the notebook at `notebook`.
103111 pub fn shown(&self, notebook: &str) -> bool {
104112 self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden)
crates/snowbound/src/windows.rs+26
......@@ -1247,8 +1247,34 @@ fn registry_dword(key: &str, value: &str) -> Option<u32> {
12471247/// Windows names no document of a window's.
12481248pub fn represent(_: &Window, _: Option<&std::path::Path>) {}
12491249
1250/// Where deleted files go, as the file manager names it.
1251pub const TRASH: &str = "Recycle Bin";
1252
12501253pub const SHOW_FILE: &str = "Show in Explorer";
12511254
1255/// Moves the file or folder at `path` to the Recycle Bin, as Explorer's Delete does.
1256pub fn trash(path: &std::path::Path) -> Result<(), String> {
1257 use std::os::windows::ffi::OsStrExt;
1258 use windows_sys::Win32::UI::Shell::{
1259 FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW,
1260 SHFileOperationW,
1261 };
1262 // A list of paths, each ended by a NUL and the list by another.
1263 let from: Vec<u16> = path.as_os_str().encode_wide().chain([0, 0]).collect();
1264 let mut operation = SHFILEOPSTRUCTW {
1265 wFunc: FO_DELETE,
1266 pFrom: from.as_ptr(),
1267 fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT) as u16,
1268 ..Default::default()
1269 };
1270 match unsafe { SHFileOperationW(&mut operation) } {
1271 0 if operation.fAnyOperationsAborted == 0 => Ok(()),
1272 code => Err(format!(
1273 "Windows couldn’t move it to the Recycle Bin (error {code:#x})."
1274 )),
1275 }
1276}
1277
12521278/// Opens an Explorer window with `file` selected.
12531279pub fn show_file(file: &std::path::Path) {
12541280 use std::os::windows::process::CommandExt;
crates/snowbound/tests/replay.rs+43
......@@ -876,3 +876,46 @@ fn dragging_a_notebook_row_reorders_the_notebooks() {
876876 assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}");
877877 assert_eq!(notebooks(&after), ["Cloudy", "notebook"], "{after}");
878878}
879
880/// A notebook at the top of the iCloud folder, which Close can't take from the sidebar, offers
881/// Delete Notebook in its menu instead.
882#[test]
883fn an_icloud_notebook_offers_delete_in_place_of_close() {
884 let scratch = Scratch::new("icloud-delete");
885 let notebook =
886 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
887 let cloudy = scratch.0.join("icloud/Cloudy");
888 copy_notebook(&notebook, &cloudy);
889 let settings = serde_json::json!({
890 "user_name": "Snowbound Test",
891 "sidebar": true,
892 "notebooks": [cloudy],
893 });
894 std::fs::write(scratch.0.join("settings.json"), settings.to_string()).unwrap();
895 let steps = [
896 "wait 500",
897 "settle",
898 "move 60 76",
899 "press right",
900 "release right",
901 ];
902 let [menu] = replay(
903 &scratch,
904 Some(&notebook),
905 &[&steps[..], &["accessibility menu"]].concat(),
906 )
907 .try_into()
908 .unwrap();
909 let items = menu_items(&menu);
910 let close = items.iter().position(|item| *item == "Close This Notebook");
911 assert_eq!(
912 close.map(|at| items[at + 1]),
913 Some("Delete Notebook"),
914 "{menu}"
915 );
916 assert!(
917 menu.lines()
918 .any(|line| line.contains(r#""Close This Notebook""#) && line.contains("[disabled]")),
919 "{menu}"
920 );
921}