| author | |
| committer | |
| log | 75c421209dfa9de6f3d15319559844f50ece7cc2 |
| tree | d92b0ea6b5a17609c7f82bda41d83ccf9c7da580 |
| parent | 91ad7c52e4867559ba209810469eea16612dcca8 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.514 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 | 55 | `session::Section::open(file, cache_dir, notify)` opens one section file through |
| 56 | 56 | a replica named by the file's location and the section's document identity, so the same |
| 57 | 57 | file 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, | |
| 59 | and `forget` for one it deletes). | |
| 59 | 60 | `Notebook::section(path, notify)` opens a mounted notebook's section; a replica already |
| 60 | 61 | named by the identity its discovery read resumes without reading the file, which the |
| 61 | 62 | worker 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 | 62 | Ok(()) |
| 63 | 63 | } |
| 64 | 64 | |
| 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. | |
| 68 | pub 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 | 86 | /// The folder for `location`, made and labelled with it where it was not. |
| 66 | 87 | fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> { |
| 67 | 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 | 1854 | } |
| 1855 | 1855 | |
| 1856 | 1856 | /// Where the cache keeps what reading the catalog of the notebook at `location` took. |
| 1857 | fn listing(cache: &Path, location: &str) -> PathBuf { | |
| 1857 | pub(crate) fn listing(cache: &Path, location: &str) -> PathBuf { | |
| 1858 | 1858 | let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16] |
| 1859 | 1859 | .iter() |
| 1860 | 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 | 348 | assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original); |
| 349 | 349 | assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original); |
| 350 | 350 | } |
| 351 | ||
| 352 | #[test] | |
| 353 | fn 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 | 573 | Ok(to) |
| 574 | 574 | } |
| 575 | 575 | |
| 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 | 603 | /// The art the notebook's tags draw with. |
| 577 | 604 | pub fn tag_art(&self) -> Arc<TagArt> { |
| 578 | 605 | Arc::clone( |
crates/snowbound/src/linux.rs+17| ... | ... | @@ -1231,8 +1231,25 @@ fn innermost(value: OwnedValue) -> Option<OwnedValue> { |
| 1231 | 1231 | value.try_into_owned().ok() |
| 1232 | 1232 | } |
| 1233 | 1233 | |
| 1234 | /// Where deleted files go, as the file manager names it. | |
| 1235 | pub const TRASH: &str = "Trash"; | |
| 1236 | ||
| 1234 | 1237 | pub const SHOW_FILE: &str = "Show in Files"; |
| 1235 | 1238 | |
| 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. | |
| 1241 | pub 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 | 1253 | /// Selects `file` in the desktop's file manager, or opens its folder where none answers |
| 1237 | 1254 | /// `org.freedesktop.FileManager1`. |
| 1238 | 1255 | pub 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 | 512 | None |
| 513 | 513 | } |
| 514 | 514 | |
| 515 | /// Where deleted files go, as the file manager names it. | |
| 516 | pub const TRASH: &str = "Trash"; | |
| 517 | ||
| 515 | 518 | pub const SHOW_FILE: &str = "Show in Finder"; |
| 516 | 519 | |
| 517 | 520 | /// Opens a Finder window with `file` selected. |
| ... | ... | @@ -1619,6 +1622,24 @@ pub fn update_menu(statuses: impl FnOnce() -> Vec<commands::Status>) { |
| 1619 | 1622 | STATUSES.set(statuses()); |
| 1620 | 1623 | } |
| 1621 | 1624 | |
| 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. | |
| 1628 | pub 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 | 1643 | /// Opens `target`, a folder or a link's URL, as Finder would. |
| 1623 | 1644 | pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { |
| 1624 | 1645 | let target = target.as_ref(); |
crates/snowbound/src/main.rs+3| ... | ... | @@ -856,6 +856,8 @@ struct State { |
| 856 | 856 | icloud_reading: HashSet<String>, |
| 857 | 857 | /// The notebook locations a folder rename takes a notebook from and to, while it does. |
| 858 | 858 | folder_rename: Option<(String, String)>, |
| 859 | /// The notebook locations whose folders are on their way to the Trash. | |
| 860 | trashing: HashSet<String>, | |
| 859 | 861 | /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. |
| 860 | 862 | tags: Vec<canvas::editor::NoteTag>, |
| 861 | 863 | /// The Customize Tags dialog's list while it is open. |
| ... | ... | @@ -1322,6 +1324,7 @@ impl State { |
| 1322 | 1324 | server: None, |
| 1323 | 1325 | icloud_reading: HashSet::new(), |
| 1324 | 1326 | folder_rename: None, |
| 1327 | trashing: HashSet::new(), | |
| 1325 | 1328 | tags: stored |
| 1326 | 1329 | .tags |
| 1327 | 1330 | .unwrap_or_else(canvas::editor::NoteTag::defaults), |
crates/snowbound/src/manage.rs+80| ... | ... | @@ -307,6 +307,7 @@ impl State { |
| 307 | 307 | in_icloud_folder(&library.location) |
| 308 | 308 | && !listed.contains(&library.location) |
| 309 | 309 | && !self.folder_renaming(&library.location) |
| 310 | && !self.trashing.contains(&library.location) | |
| 310 | 311 | // A listing taken before a rename names the folder by its old name. |
| 311 | 312 | && notebook::fs::metadata(&library.location).is_err() |
| 312 | 313 | }) |
| ... | ... | @@ -318,6 +319,7 @@ impl State { |
| 318 | 319 | for location in listed { |
| 319 | 320 | if self.notebooks.iter().any(|open| open.location == location) |
| 320 | 321 | || self.folder_renaming(&location) |
| 322 | || self.trashing.contains(&location) | |
| 321 | 323 | || !self.icloud_reading.insert(location.clone()) |
| 322 | 324 | { |
| 323 | 325 | continue; |
| ... | ... | @@ -331,6 +333,8 @@ impl State { |
| 331 | 333 | if gone |
| 332 | 334 | || state.notebooks.iter().any(|open| open.location == location) |
| 333 | 335 | || state.folder_renaming(&location) |
| 336 | || state.trashing.contains(&location) | |
| 337 | || notebook::fs::metadata(&location).is_err() | |
| 334 | 338 | { |
| 335 | 339 | return Ok(()); |
| 336 | 340 | } |
| ... | ... | @@ -535,6 +539,82 @@ impl State { |
| 535 | 539 | self.save_settings(); |
| 536 | 540 | } |
| 537 | 541 | |
| 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 | 618 | /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed |
| 539 | 619 | /// meanwhile, then opens it from there in its place, at the section it showed, with `color` |
| 540 | 620 | /// 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 | 549 | let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); |
| 550 | 550 | let last = self.notebooks.len().saturating_sub(1); |
| 551 | 551 | let unread = self.unread_notebook(library); |
| 552 | vec![ | |
| 552 | let mut actions = vec![ | |
| 553 | 553 | item(Action::Rename, "Rename", false, false), |
| 554 | 554 | item( |
| 555 | 555 | Action::SaveAs, |
| ... | ... | @@ -570,6 +570,16 @@ impl State { |
| 570 | 570 | crate::manage::in_icloud_folder(&library.location), |
| 571 | 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 | 583 | item(Action::CopyLink, "Copy Link to Notebook", false, true), |
| 574 | 584 | #[cfg(feature = "live")] |
| 575 | 585 | item( |
| ... | ... | @@ -627,7 +637,8 @@ impl State { |
| 627 | 637 | false, |
| 628 | 638 | ), |
| 629 | 639 | styles(library), |
| 630 | ] | |
| 640 | ]); | |
| 641 | actions | |
| 631 | 642 | } |
| 632 | 643 | Target::Closed(_) | Target::Server(_) => { |
| 633 | 644 | vec![item(Action::Delete, "Remove from Recent", false, false)] |
| ... | ... | @@ -931,6 +942,11 @@ impl State { |
| 931 | 942 | None |
| 932 | 943 | } |
| 933 | 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 | 950 | #[cfg(feature = "live")] |
| 935 | 951 | (Target::Notebook(library), Action::LiveShare) => { |
| 936 | 952 | self.open_live_share(library); |
crates/snowbound/src/navigation.rs+9| ... | ... | @@ -67,6 +67,15 @@ impl Trail { |
| 67 | 67 | } |
| 68 | 68 | } |
| 69 | 69 | |
| 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 | 79 | /// Notes `place` shown, as the recent pages list it. |
| 71 | 80 | pub fn remember(&mut self, place: Place) { |
| 72 | 81 | self.recent.retain(|recent| *recent != place); |
crates/snowbound/src/unread.rs+8| ... | ... | @@ -99,6 +99,14 @@ impl Reads { |
| 99 | 99 | } |
| 100 | 100 | } |
| 101 | 101 | |
| 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 | 110 | /// Whether Show Unread Changes is on for the notebook at `notebook`. |
| 103 | 111 | pub fn shown(&self, notebook: &str) -> bool { |
| 104 | 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 | 1247 | /// Windows names no document of a window's. |
| 1248 | 1248 | pub fn represent(_: &Window, _: Option<&std::path::Path>) {} |
| 1249 | 1249 | |
| 1250 | /// Where deleted files go, as the file manager names it. | |
| 1251 | pub const TRASH: &str = "Recycle Bin"; | |
| 1252 | ||
| 1250 | 1253 | pub const SHOW_FILE: &str = "Show in Explorer"; |
| 1251 | 1254 | |
| 1255 | /// Moves the file or folder at `path` to the Recycle Bin, as Explorer's Delete does. | |
| 1256 | pub 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 | 1278 | /// Opens an Explorer window with `file` selected. |
| 1253 | 1279 | pub fn show_file(file: &std::path::Path) { |
| 1254 | 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 | 876 | assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}"); |
| 877 | 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] | |
| 883 | fn 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 | } |