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}" + ); +}