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...@@ -55,7 +55,8 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k
55`session::Section::open(file, cache_dir, notify)` opens one section file through55`session::Section::open(file, cache_dir, notify)` opens one section file through
56a replica named by the file's location and the section's document identity, so the same56a replica named by the file's location and the section's document identity, so the same
57file reopens the same queue after a relaunch and a copy of it elsewhere has its own57file 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).
59`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already60`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already
60named by the identity its discovery read resumes without reading the file, which the61named by the identity its discovery read resumes without reading the file, which the
61worker checks next, as on a share.62worker 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<()> {...@@ -62,6 +62,27 @@ pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> {
62 Ok(())62 Ok(())
63}63}
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
65/// The folder for `location`, made and labelled with it where it was not.86/// The folder for `location`, made and labelled with it where it was not.
66fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {87fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> {
67 let folder = folder(cache, location);88 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> {...@@ -1854,7 +1854,7 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {
1854}1854}
18551855
1856/// Where the cache keeps what reading the catalog of the notebook at `location` took.1856/// 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 {
1858 let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16]1858 let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16]
1859 .iter()1859 .iter()
1860 .map(|byte| format!("{byte:02x}"))1860 .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() {...@@ -348,3 +348,21 @@ fn renaming_reordering_or_deleting_a_copy_leaves_the_originals_toc_entry() {
348 assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original);348 assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original);
349 assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original);349 assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original);
350}350}
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 {...@@ -573,6 +573,33 @@ impl Library {
573 Ok(to)573 Ok(to)
574 }574 }
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
576 /// The art the notebook's tags draw with.603 /// The art the notebook's tags draw with.
577 pub fn tag_art(&self) -> Arc<TagArt> {604 pub fn tag_art(&self) -> Arc<TagArt> {
578 Arc::clone(605 Arc::clone(
crates/snowbound/src/linux.rs+17
...@@ -1231,8 +1231,25 @@ fn innermost(value: OwnedValue) -> Option<OwnedValue> {...@@ -1231,8 +1231,25 @@ fn innermost(value: OwnedValue) -> Option<OwnedValue> {
1231 value.try_into_owned().ok()1231 value.try_into_owned().ok()
1232}1232}
12331233
1234/// Where deleted files go, as the file manager names it.
1235pub const TRASH: &str = "Trash";
1236
1234pub const SHOW_FILE: &str = "Show in Files";1237pub 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
1236/// Selects `file` in the desktop's file manager, or opens its folder where none answers1253/// Selects `file` in the desktop's file manager, or opens its folder where none answers
1237/// `org.freedesktop.FileManager1`.1254/// `org.freedesktop.FileManager1`.
1238pub fn show_file(file: &std::path::Path) {1255pub 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...@@ -512,6 +512,9 @@ pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::Resize
512 None512 None
513}513}
514514
515/// Where deleted files go, as the file manager names it.
516pub const TRASH: &str = "Trash";
517
515pub const SHOW_FILE: &str = "Show in Finder";518pub const SHOW_FILE: &str = "Show in Finder";
516519
517/// Opens a Finder window with `file` selected.520/// Opens a Finder window with `file` selected.
...@@ -1619,6 +1622,24 @@ pub fn update_menu(statuses: impl FnOnce() -> Vec<commands::Status>) {...@@ -1619,6 +1622,24 @@ pub fn update_menu(statuses: impl FnOnce() -> Vec<commands::Status>) {
1619 STATUSES.set(statuses());1622 STATUSES.set(statuses());
1620}1623}
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
1622/// Opens `target`, a folder or a link's URL, as Finder would.1643/// Opens `target`, a folder or a link's URL, as Finder would.
1623pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {1644pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1624 let target = target.as_ref();1645 let target = target.as_ref();
crates/snowbound/src/main.rs+3
...@@ -856,6 +856,8 @@ struct State {...@@ -856,6 +856,8 @@ struct State {
856 icloud_reading: HashSet<String>,856 icloud_reading: HashSet<String>,
857 /// The notebook locations a folder rename takes a notebook from and to, while it does.857 /// The notebook locations a folder rename takes a notebook from and to, while it does.
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.
860 trashing: HashSet<String>,
859 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.861 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
860 tags: Vec<canvas::editor::NoteTag>,862 tags: Vec<canvas::editor::NoteTag>,
861 /// The Customize Tags dialog's list while it is open.863 /// The Customize Tags dialog's list while it is open.
...@@ -1322,6 +1324,7 @@ impl State {...@@ -1322,6 +1324,7 @@ impl State {
1322 server: None,1324 server: None,
1323 icloud_reading: HashSet::new(),1325 icloud_reading: HashSet::new(),
1324 folder_rename: None,1326 folder_rename: None,
1327 trashing: HashSet::new(),
1325 tags: stored1328 tags: stored
1326 .tags1329 .tags
1327 .unwrap_or_else(canvas::editor::NoteTag::defaults),1330 .unwrap_or_else(canvas::editor::NoteTag::defaults),
crates/snowbound/src/manage.rs+80
...@@ -307,6 +307,7 @@ impl State {...@@ -307,6 +307,7 @@ impl State {
307 in_icloud_folder(&library.location)307 in_icloud_folder(&library.location)
308 && !listed.contains(&library.location)308 && !listed.contains(&library.location)
309 && !self.folder_renaming(&library.location)309 && !self.folder_renaming(&library.location)
310 && !self.trashing.contains(&library.location)
310 // A listing taken before a rename names the folder by its old name.311 // A listing taken before a rename names the folder by its old name.
311 && notebook::fs::metadata(&library.location).is_err()312 && notebook::fs::metadata(&library.location).is_err()
312 })313 })
...@@ -318,6 +319,7 @@ impl State {...@@ -318,6 +319,7 @@ impl State {
318 for location in listed {319 for location in listed {
319 if self.notebooks.iter().any(|open| open.location == location)320 if self.notebooks.iter().any(|open| open.location == location)
320 || self.folder_renaming(&location)321 || self.folder_renaming(&location)
322 || self.trashing.contains(&location)
321 || !self.icloud_reading.insert(location.clone())323 || !self.icloud_reading.insert(location.clone())
322 {324 {
323 continue;325 continue;
...@@ -331,6 +333,8 @@ impl State {...@@ -331,6 +333,8 @@ impl State {
331 if gone333 if gone
332 || state.notebooks.iter().any(|open| open.location == location)334 || state.notebooks.iter().any(|open| open.location == location)
333 || state.folder_renaming(&location)335 || state.folder_renaming(&location)
336 || state.trashing.contains(&location)
337 || notebook::fs::metadata(&location).is_err()
334 {338 {
335 return Ok(());339 return Ok(());
336 }340 }
...@@ -535,6 +539,82 @@ impl State {...@@ -535,6 +539,82 @@ impl State {
535 self.save_settings();539 self.save_settings();
536 }540 }
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
538 /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed618 /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed
539 /// meanwhile, then opens it from there in its place, at the section it showed, with `color`619 /// meanwhile, then opens it from there in its place, at the section it showed, with `color`
540 /// given it. What this computer keeps by the notebook's location follows it. Refused, the620 /// 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 {...@@ -549,7 +549,7 @@ impl State {
549 let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library));549 let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library));
550 let last = self.notebooks.len().saturating_sub(1);550 let last = self.notebooks.len().saturating_sub(1);
551 let unread = self.unread_notebook(library);551 let unread = self.unread_notebook(library);
552 vec![552 let mut actions = vec![
553 item(Action::Rename, "Rename", false, false),553 item(Action::Rename, "Rename", false, false),
554 item(554 item(
555 Action::SaveAs,555 Action::SaveAs,
...@@ -570,6 +570,16 @@ impl State {...@@ -570,6 +570,16 @@ impl State {
570 crate::manage::in_icloud_folder(&library.location),570 crate::manage::in_icloud_folder(&library.location),
571 false,571 false,
572 ),572 ),
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([
573 item(Action::CopyLink, "Copy Link to Notebook", false, true),583 item(Action::CopyLink, "Copy Link to Notebook", false, true),
574 #[cfg(feature = "live")]584 #[cfg(feature = "live")]
575 item(585 item(
...@@ -627,7 +637,8 @@ impl State {...@@ -627,7 +637,8 @@ impl State {
627 false,637 false,
628 ),638 ),
629 styles(library),639 styles(library),
630 ]640 ]);
641 actions
631 }642 }
632 Target::Closed(_) | Target::Server(_) => {643 Target::Closed(_) | Target::Server(_) => {
633 vec![item(Action::Delete, "Remove from Recent", false, false)]644 vec![item(Action::Delete, "Remove from Recent", false, false)]
...@@ -931,6 +942,11 @@ impl State {...@@ -931,6 +942,11 @@ impl State {
931 None942 None
932 }943 }
933 (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)),944 (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 }
934 #[cfg(feature = "live")]950 #[cfg(feature = "live")]
935 (Target::Notebook(library), Action::LiveShare) => {951 (Target::Notebook(library), Action::LiveShare) => {
936 self.open_live_share(library);952 self.open_live_share(library);
crates/snowbound/src/navigation.rs+9
...@@ -67,6 +67,15 @@ impl Trail {...@@ -67,6 +67,15 @@ impl Trail {
67 }67 }
68 }68 }
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
70 /// Notes `place` shown, as the recent pages list it.79 /// Notes `place` shown, as the recent pages list it.
71 pub fn remember(&mut self, place: Place) {80 pub fn remember(&mut self, place: Place) {
72 self.recent.retain(|recent| *recent != place);81 self.recent.retain(|recent| *recent != place);
crates/snowbound/src/unread.rs+8
...@@ -99,6 +99,14 @@ impl Reads {...@@ -99,6 +99,14 @@ impl Reads {
99 }99 }
100 }100 }
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
102 /// Whether Show Unread Changes is on for the notebook at `notebook`.110 /// Whether Show Unread Changes is on for the notebook at `notebook`.
103 pub fn shown(&self, notebook: &str) -> bool {111 pub fn shown(&self, notebook: &str) -> bool {
104 self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden)112 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> {...@@ -1247,8 +1247,34 @@ fn registry_dword(key: &str, value: &str) -> Option<u32> {
1247/// Windows names no document of a window's.1247/// Windows names no document of a window's.
1248pub fn represent(_: &Window, _: Option<&std::path::Path>) {}1248pub 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
1250pub const SHOW_FILE: &str = "Show in Explorer";1253pub 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
1252/// Opens an Explorer window with `file` selected.1278/// Opens an Explorer window with `file` selected.
1253pub fn show_file(file: &std::path::Path) {1279pub fn show_file(file: &std::path::Path) {
1254 use std::os::windows::process::CommandExt;1280 use std::os::windows::process::CommandExt;
crates/snowbound/tests/replay.rs+43
...@@ -876,3 +876,46 @@ fn dragging_a_notebook_row_reorders_the_notebooks() {...@@ -876,3 +876,46 @@ fn dragging_a_notebook_row_reorders_the_notebooks() {
876 assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}");876 assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}");
877 assert_eq!(notebooks(&after), ["Cloudy", "notebook"], "{after}");877 assert_eq!(notebooks(&after), ["Cloudy", "notebook"], "{after}");
878}878}
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}