From 75c421209dfa9de6f3d15319559844f50ece7cc2 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 21:23:37 -0700 Subject: [PATCH] feat: Delete Notebook moves a notebook's folder to the Trash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/notebook/README.md | 3 +- crates/notebook/src/location.rs | 21 ++++++++ crates/notebook/src/session.rs | 2 +- crates/notebook/tests/location.rs | 18 +++++++ crates/snowbound/src/library.rs | 27 ++++++++++ crates/snowbound/src/linux.rs | 17 +++++++ crates/snowbound/src/macos.rs | 21 ++++++++ crates/snowbound/src/main.rs | 3 ++ crates/snowbound/src/manage.rs | 80 ++++++++++++++++++++++++++++++ crates/snowbound/src/menus.rs | 20 +++++++- crates/snowbound/src/navigation.rs | 9 ++++ crates/snowbound/src/unread.rs | 8 +++ crates/snowbound/src/windows.rs | 26 ++++++++++ crates/snowbound/tests/replay.rs | 43 ++++++++++++++++ 14 files changed, 294 insertions(+), 4 deletions(-) diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 2045ad344ae63810a9523e076444be0097138b03..405af56e90ea92a735715c39430ec9664ca83ac2 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -55,7 +55,8 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k `session::Section::open(file, cache_dir, notify)` opens one section file through a replica named by the file's location and the section's document identity, so the same file reopens the same queue after a relaunch and a copy of it elsewhere has its own -(`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves). +(`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves, +and `forget` for one it deletes). `Notebook::section(path, notify)` opens a mounted notebook's section; a replica already named by the identity its discovery read resumes without reading the file, which the worker checks next, as on a share. diff --git a/crates/notebook/src/location.rs b/crates/notebook/src/location.rs index b8d1278eaab9910d4b45d3e1441f5e143e1ff49d..6a138373dd3a463078bf509e338448c0ea59337a 100644 --- a/crates/notebook/src/location.rs +++ b/crates/notebook/src/location.rs @@ -62,6 +62,27 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { Ok(()) } +/// Deletes what the cache keeps of the notebook at `location`, as when the app deletes the +/// notebook: its replicas, edits waiting in them included, its sections' copies' and its +/// listing. Every replica must be closed. +pub fn forget(cache: &Path, location: &str) -> io::Result<()> { + let within = format!("{location}/"); + if let Ok(entries) = fs::read_dir(cache.join("replicas")) { + for entry in entries { + let folder = entry?.path(); + let ours = fs::read_to_string(folder.join(LOCATION)) + .is_ok_and(|label| label == location || label.starts_with(&within)); + if ours { + fs::remove_dir_all(&folder)?; + } + } + } + match fs::remove_file(crate::session::listing(cache, location)) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + /// The folder for `location`, made and labelled with it where it was not. fn claimed(cache: &Path, location: &str) -> io::Result { let folder = folder(cache, location); diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 1eac026e7c60bb5cb7f99bfcc523012297ebc9ae..566499678d3adc3e0465eacb622c67aee93f8d77 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -1854,7 +1854,7 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result { } /// Where the cache keeps what reading the catalog of the notebook at `location` took. -fn listing(cache: &Path, location: &str) -> PathBuf { +pub(crate) fn listing(cache: &Path, location: &str) -> PathBuf { let name: String = ::digest(location)[..16] .iter() .map(|byte| format!("{byte:02x}")) diff --git a/crates/notebook/tests/location.rs b/crates/notebook/tests/location.rs index 81f9cb33b4fef70cd1a593336e6838898ae59159..bcb13abca7d4dee92c9267b4b1f3e0c0a9566158 100644 --- a/crates/notebook/tests/location.rs +++ b/crates/notebook/tests/location.rs @@ -348,3 +348,21 @@ fn renaming_reordering_or_deleting_a_copy_leaves_the_originals_toc_entry() { assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original); assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original); } + +#[test] +fn forgetting_a_deleted_notebook_drops_its_replicas_and_queue_alone() { + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("cache"); + let root = notebook(directory.path(), "Notebook"); + let other = notebook(directory.path(), "Other"); + queued(&root, &cache, "Queued "); + queued(&other, &cache, "Kept "); + let location = notebook::location::local(&root).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + notebook::location::forget(&cache, &location).unwrap(); + assert!(std::fs::metadata(notebook::location::folder(&cache, &location)).is_err()); + // A notebook made where it was takes nothing of the queue; another keeps its own. + notebook(directory.path(), "Notebook"); + assert_eq!(published(&root, &cache), 0); + assert_eq!(published(&other, &cache), 1); +} diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index cd122592d2567d041215e39f8971c69b90e7bc06..4c244307bcfda90d836cb91347026ed40c924531 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -573,6 +573,33 @@ impl Library { Ok(to) } + /// Moves the notebook's folder to the Trash once nothing of this notebook holds its files, + /// its background stopped and its kept sections closed as the open section must be too, + /// then forgets what this computer keeps of it: its replicas, edits waiting in them + /// included, and its display name. Returns why not, as its reader is told. + #[cfg(not(target_arch = "wasm32"))] + pub fn trash(&self) -> Result<(), String> { + let folder = self + .folder() + .ok_or("This notebook’s folder can’t be moved to the Trash.")?; + if let Some(background) = &self.background { + background.stop(); + } + self.close_kept(); + // As its replicas' folders name it. + let replicas = notebook::location::local(folder).map_err(|error| error.to_string())?; + crate::platform::trash(folder)?; + let forgotten = notebook::location::forget(&self.cache, &replicas).and_then(|()| { + edit_display_names(&self.cache, |names| { + names.remove(&self.location); + }) + }); + if let Err(error) = forgotten { + eprintln!("{}: {error}", self.location); + } + Ok(()) + } + /// The art the notebook's tags draw with. pub fn tag_art(&self) -> Arc { Arc::clone( diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index e50c02e9a61e2975ddc8964dc5e851bb750068b4..1e330dcdc09801059929e7eb6472cae4964413f7 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -1231,8 +1231,25 @@ fn innermost(value: OwnedValue) -> Option { value.try_into_owned().ok() } +/// Where deleted files go, as the file manager names it. +pub const TRASH: &str = "Trash"; + pub const SHOW_FILE: &str = "Show in Files"; +/// Moves the file or folder at `path` to the desktop's trash, through GIO, as the file +/// manager's Move to Trash does. +pub fn trash(path: &std::path::Path) -> Result<(), String> { + let output = std::process::Command::new("gio") + .arg("trash") + .arg(path) + .output() + .map_err(|error| format!("Couldn’t run gio to move it to the trash: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()) +} + /// Selects `file` in the desktop's file manager, or opens its folder where none answers /// `org.freedesktop.FileManager1`. pub fn show_file(file: &std::path::Path) { diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 362b4ee310152986edb0db2d83286be9d13964f6..40138b491265420dea41561963f1f15e2d20593c 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -512,6 +512,9 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option Vec) { STATUSES.set(statuses()); } +/// Moves the file or folder at `path` to the Trash, as the Finder's Move to Trash does, where +/// Put Back restores it; in iCloud Drive, to the Trash of every device signed in to it. +/// Refused before 10.8, whose file manager can't. +pub fn trash(path: &std::path::Path) -> Result<(), String> { + let manager = unsafe { objc2_foundation::NSFileManager::defaultManager() }; + let can: bool = unsafe { + msg_send![&manager, respondsToSelector: sel!(trashItemAtURL:resultingItemURL:error:)] + }; + if !can { + return Err("This version of macOS can’t move folders to the Trash.".into()); + } + let url = unsafe { + objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&path.to_string_lossy())) + }; + unsafe { manager.trashItemAtURL_resultingItemURL_error(&url, None) } + .map_err(|error| error.localizedDescription().to_string()) +} + /// Opens `target`, a folder or a link's URL, as Finder would. pub fn reveal(target: impl AsRef) { let target = target.as_ref(); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index d23ba0148df95a0c43d3e085b2d375d21ea4d553..fc0848e8ea777829707cef935e64f8b7cb6f9842 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -856,6 +856,8 @@ struct State { icloud_reading: HashSet, /// The notebook locations a folder rename takes a notebook from and to, while it does. folder_rename: Option<(String, String)>, + /// The notebook locations whose folders are on their way to the Trash. + trashing: 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. @@ -1322,6 +1324,7 @@ impl State { server: None, icloud_reading: HashSet::new(), folder_rename: None, + trashing: 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 74947353eeb451ad704d4f1ccd56873d9e3af06e..241cff0123433126b44538b77bb9f35670554192 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -307,6 +307,7 @@ impl State { 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() }) @@ -318,6 +319,7 @@ impl State { for location in listed { if self.notebooks.iter().any(|open| open.location == location) || self.folder_renaming(&location) + || self.trashing.contains(&location) || !self.icloud_reading.insert(location.clone()) { continue; @@ -331,6 +333,8 @@ impl State { if gone || state.notebooks.iter().any(|open| open.location == location) || state.folder_renaming(&location) + || state.trashing.contains(&location) + || notebook::fs::metadata(&location).is_err() { return Ok(()); } @@ -535,6 +539,82 @@ impl State { self.save_settings(); } + /// Asks whether to move `library`'s folder to the Trash, telling of changes not yet in its + /// files, which go with it, then does. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn delete_notebook(&mut self, library: Arc) { + // What was typed last counts too. + self.persist().unwrap_or_else(|error| eprintln!("{error}")); + let open = (self.session.as_ref()) + .filter(|session| Arc::ptr_eq(&session.library, &library)) + .and_then(|session| session.section.pending().ok()) + .map_or(0, |pending| pending.len() as u64); + let waiting = (library.background.iter()) + .flat_map(|background| background.status()) + .map(|(_, sync)| sync.queued) + .sum::() + .max(open); + let mut detail = if in_icloud_folder(&library.location) { + "It moves to the Trash on every device signed in to this iCloud account.".to_owned() + } else { + format!("It moves to the {}.", platform::TRASH) + }; + if waiting > 0 { + let changes = match waiting { + 1 => "1 change".to_owned(), + count => format!("{count} changes"), + }; + detail += &format!(" {changes} not yet saved to its files will be lost."); + } + platform::confirm( + &format!("Delete “{}”?", library.name), + &detail, + "Cancel", + "Delete", + self.reply(move |state, ()| { + state.trash_notebook(library); + Ok(()) + }), + ); + } + + /// Closes `library` and moves its folder to the Trash on a thread of its own, forgetting + /// what this computer and the next launch keep of it. Refused, it opens again. + #[cfg(not(target_arch = "wasm32"))] + fn trash_notebook(&mut self, library: Arc) { + let location = library.location.clone(); + if let Some(session) = + (self.session).take_if(|session| Arc::ptr_eq(&session.library, &library)) + { + // Its replica goes with it, so nothing may hold it. + let _ = self.view.editor.take_ops(); + if let Err(error) = session.section.close() { + eprintln!("{location}: {error}"); + } + self.sectionless = Some(Arc::clone(&library)); + } + self.trashing.insert(location.clone()); + self.close_notebook(&library); + self.trail.forget(&location); + self.reads.forget(&location); + let ours = crate::library::key(&location, ""); + self.folded.retain(|key| !key.starts_with(&ours)); + self.last_pages.retain(|key, _| !key.starts_with(&ours)); + self.save_settings(); + let proxy = self.proxy.clone(); + crate::spawn(move || { + let trashed = library.trash(); + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + state.trashing.remove(&location); + if let Err(problem) = trashed { + platform::alert("Couldn’t delete the notebook", &problem); + state.open_notebook(location, None); + } + Ok(()) + }))); + }); + } + /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed /// meanwhile, then opens it from there in its place, at the section it showed, with `color` /// given it. What this computer keeps by the notebook's location follows it. Refused, the diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 31e07f43dc740ba3b45d6c5f7bd8d3967fc92acb..a6009b5e868b98fbbf0b9ca9176f792265fef471 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -549,7 +549,7 @@ impl State { let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); let last = self.notebooks.len().saturating_sub(1); let unread = self.unread_notebook(library); - vec![ + let mut actions = vec![ item(Action::Rename, "Rename", false, false), item( Action::SaveAs, @@ -570,6 +570,16 @@ impl State { crate::manage::in_icloud_folder(&library.location), false, ), + ]; + // Its folder to the Trash, as the Finder deletes it; a server's stays there. + if cfg!(not(target_arch = "wasm32")) + && library.folder().is_some() + && !library.on_smb() + && !matches!(library.notebook, Ok(None)) + { + actions.push(item(Action::Delete, "Delete Notebook", false, false)); + } + actions.extend([ item(Action::CopyLink, "Copy Link to Notebook", false, true), #[cfg(feature = "live")] item( @@ -627,7 +637,8 @@ impl State { false, ), styles(library), - ] + ]); + actions } Target::Closed(_) | Target::Server(_) => { vec![item(Action::Delete, "Remove from Recent", false, false)] @@ -931,6 +942,11 @@ impl State { None } (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)), + #[cfg(not(target_arch = "wasm32"))] + (Target::Notebook(library), Action::Delete) => { + self.delete_notebook(library); + None + } #[cfg(feature = "live")] (Target::Notebook(library), Action::LiveShare) => { self.open_live_share(library); diff --git a/crates/snowbound/src/navigation.rs b/crates/snowbound/src/navigation.rs index c77ed6795b6532f30ef044ca6d1a984b8c636493..8529053ea18912febcf5471285662c326dc67919 100644 --- a/crates/snowbound/src/navigation.rs +++ b/crates/snowbound/src/navigation.rs @@ -67,6 +67,15 @@ impl Trail { } } + /// Forgets every place in the notebook at `location`, which is gone. + pub fn forget(&mut self, location: &str) { + let elsewhere = |visit: &Visit| visit.notebook != location; + self.back.retain(elsewhere); + self.forward.retain(elsewhere); + self.here = self.here.take().filter(elsewhere); + self.recent.retain(|place| place.notebook != location); + } + /// Notes `place` shown, as the recent pages list it. pub fn remember(&mut self, place: Place) { self.recent.retain(|recent| *recent != place); diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs index 14fba330d7d4e6397b7f646c0548d841d4713798..2e605a8a4e63e4cecb007d78a415968db932d200 100644 --- a/crates/snowbound/src/unread.rs +++ b/crates/snowbound/src/unread.rs @@ -99,6 +99,14 @@ impl Reads { } } + /// Forgets what was read in the notebook at `notebook`, which is gone. + pub fn forget(&mut self, notebook: &str) { + self.viewing = (self.viewing.take()).filter(|(viewing, ..)| viewing != notebook); + if self.notebooks.remove(notebook).is_some() { + self.save(); + } + } + /// Whether Show Unread Changes is on for the notebook at `notebook`. pub fn shown(&self, notebook: &str) -> bool { self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden) diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index f561a036d5bf4c6736f6f4c496ab4ff7fcb50480..dc1da2c144402b325c80f48208ece5d2d16d0bd5 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -1247,8 +1247,34 @@ fn registry_dword(key: &str, value: &str) -> Option { /// Windows names no document of a window's. pub fn represent(_: &Window, _: Option<&std::path::Path>) {} +/// Where deleted files go, as the file manager names it. +pub const TRASH: &str = "Recycle Bin"; + pub const SHOW_FILE: &str = "Show in Explorer"; +/// Moves the file or folder at `path` to the Recycle Bin, as Explorer's Delete does. +pub fn trash(path: &std::path::Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::Shell::{ + FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW, + SHFileOperationW, + }; + // A list of paths, each ended by a NUL and the list by another. + let from: Vec = path.as_os_str().encode_wide().chain([0, 0]).collect(); + let mut operation = SHFILEOPSTRUCTW { + wFunc: FO_DELETE, + pFrom: from.as_ptr(), + fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT) as u16, + ..Default::default() + }; + match unsafe { SHFileOperationW(&mut operation) } { + 0 if operation.fAnyOperationsAborted == 0 => Ok(()), + code => Err(format!( + "Windows couldn’t move it to the Recycle Bin (error {code:#x})." + )), + } +} + /// Opens an Explorer window with `file` selected. pub fn show_file(file: &std::path::Path) { use std::os::windows::process::CommandExt; diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index 8dbb76190657aa10f144eac426e6a396acb42354..e8db0b0269ce65ef3e8f954f0fc30badb0373a05 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -876,3 +876,46 @@ fn dragging_a_notebook_row_reorders_the_notebooks() { assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}"); assert_eq!(notebooks(&after), ["Cloudy", "notebook"], "{after}"); } + +/// A notebook at the top of the iCloud folder, which Close can't take from the sidebar, offers +/// Delete Notebook in its menu instead. +#[test] +fn an_icloud_notebook_offers_delete_in_place_of_close() { + let scratch = Scratch::new("icloud-delete"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let cloudy = scratch.0.join("icloud/Cloudy"); + copy_notebook(¬ebook, &cloudy); + let settings = serde_json::json!({ + "user_name": "Snowbound Test", + "sidebar": true, + "notebooks": [cloudy], + }); + std::fs::write(scratch.0.join("settings.json"), settings.to_string()).unwrap(); + let steps = [ + "wait 500", + "settle", + "move 60 76", + "press right", + "release right", + ]; + let [menu] = replay( + &scratch, + Some(¬ebook), + &[&steps[..], &["accessibility menu"]].concat(), + ) + .try_into() + .unwrap(); + let items = menu_items(&menu); + let close = items.iter().position(|item| *item == "Close This Notebook"); + assert_eq!( + close.map(|at| items[at + 1]), + Some("Delete Notebook"), + "{menu}" + ); + assert!( + menu.lines() + .any(|line| line.contains(r#""Close This Notebook""#) && line.contains("[disabled]")), + "{menu}" + ); +} -- 2.54.0