| author | |
| committer | |
| log | 9361b752ce57b06d0f55af85dc5f515f794ea8f2 |
| tree | 3709c64653c0e308d679ba30d25e94e0fec4f7c8 |
| parent | 4c59412d43f497f29b4c3d4b9695134f634cf17f |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
History entries name the section by file identity and the page by its own
identity, so a section renamed, moved into a group or reordered, and a page
moved to another section, still resolve. Entries that no longer resolve (page
or section deleted, notebook closed) are dropped and skipped silently; Back and
Forward are disabled when nothing valid remains. Forward after renaming the
target section used to alert "Couldn't open / entity not found".
File and network failures in alerts now read in plain words ("This page was
moved or deleted.") through crate::plain.
Assisted-by: claude-opus-5.515 files changed, 515 insertions(+), 98 deletions(-)
crates/canvas/src/search.rs+3| ... | ... | @@ -285,6 +285,8 @@ pub struct Entry { |
| 285 | 285 | /// The section's key, which the host chooses. |
| 286 | 286 | pub section: String, |
| 287 | 287 | pub space: ExGuid, |
| 288 | /// The page's own identity, which it keeps moving to another section. | |
| 289 | pub identity: Option<[u8; 16]>, | |
| 288 | 290 | pub title: String, |
| 289 | 291 | /// When the page last changed, in any unit that orders. |
| 290 | 292 | pub modified: u64, |
| ... | ... | @@ -301,6 +303,7 @@ impl Entry { |
| 301 | 303 | Self { |
| 302 | 304 | section: section.to_owned(), |
| 303 | 305 | space, |
| 306 | identity: page.identity, | |
| 304 | 307 | folded_title: fold(&page.title), |
| 305 | 308 | folded_text: fold(&text), |
| 306 | 309 | title: page.title.clone(), |
crates/snowbound/src/commands.rs+3-2| ... | ... | @@ -1234,8 +1234,9 @@ impl State { |
| 1234 | 1234 | Id::Copy => enabled(selected), |
| 1235 | 1235 | Id::Paste => enabled(text && !field), |
| 1236 | 1236 | Id::SelectAll => enabled(field || page), |
| 1237 | Id::Back => enabled(!modal && self.trail.open()[0]), | |
| 1238 | Id::Forward => enabled(!modal && self.trail.open()[1]), | |
| 1237 | Id::Back | Id::Forward => { | |
| 1238 | enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)]) | |
| 1239 | } | |
| 1239 | 1240 | Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page), |
| 1240 | 1241 | Id::Sidebar => Status { |
| 1241 | 1242 | enabled: !welcome && !modal, |
crates/snowbound/src/desktop_linux.rs+7-2| ... | ... | @@ -435,7 +435,9 @@ pub fn install() { |
| 435 | 435 | "To uninstall it, open Options from the notebook menu. Uninstall is under Updates.", |
| 436 | 436 | ); |
| 437 | 437 | } |
| 438 | Err(error) => crate::platform::alert("Couldn't install Snowbound", &error.to_string()), | |
| 438 | Err(error) => { | |
| 439 | crate::platform::alert("Couldn't install Snowbound", &crate::plain(&error, "file")) | |
| 440 | } | |
| 439 | 441 | } |
| 440 | 442 | } |
| 441 | 443 | |
| ... | ... | @@ -525,7 +527,10 @@ fn remove() { |
| 525 | 527 | } |
| 526 | 528 | refresh_caches(&data); |
| 527 | 529 | match failed { |
| 528 | Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()), | |
| 530 | Some(error) => crate::platform::alert( | |
| 531 | "Couldn't uninstall Snowbound", | |
| 532 | &crate::plain(&error, "file"), | |
| 533 | ), | |
| 529 | 534 | None => *INSTALLED.lock().unwrap() = Installed::No, |
| 530 | 535 | } |
| 531 | 536 | } |
crates/snowbound/src/library.rs+6-9| ... | ... | @@ -409,7 +409,10 @@ impl Library { |
| 409 | 409 | tag_art: Mutex::new(Arc::new( |
| 410 | 410 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), |
| 411 | 411 | )), |
| 412 | notebook: notebook.map(Some).map_err(|error| error.to_string()), | |
| 412 | notebook: notebook.map(Some).map_err(|error| { | |
| 413 | eprintln!("{location}: {error}"); | |
| 414 | crate::plain(&error, "notebook") | |
| 415 | }), | |
| 413 | 416 | notice, |
| 414 | 417 | ..Self::new(location, name, cache) |
| 415 | 418 | } |
| ... | ... | @@ -544,13 +547,7 @@ impl Library { |
| 544 | 547 | .reopen() |
| 545 | 548 | .and_then(|notebook| Ok(notebook.rename_folder(name)?)); |
| 546 | 549 | if let Err(error) = renamed { |
| 547 | let kind = match error.downcast_ref::<notebook::Error>() { | |
| 548 | Some(notebook::Error::Io(error) | notebook::Error::RemoteIo(error)) => { | |
| 549 | Some(error.kind()) | |
| 550 | } | |
| 551 | _ => error.downcast_ref::<io::Error>().map(io::Error::kind), | |
| 552 | }; | |
| 553 | return Err(match kind { | |
| 550 | return Err(match crate::io_kind(&*error) { | |
| 554 | 551 | Some(io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy) => { |
| 555 | 552 | "Another computer is saving to this notebook. Try again in a moment.".into() |
| 556 | 553 | } |
| ... | ... | @@ -564,7 +561,7 @@ impl Library { |
| 564 | 561 | Some(io::ErrorKind::InvalidInput) => "A folder name can’t contain \\ / : * ? \" \ |
| 565 | 562 | < > | or end with a dot or space." |
| 566 | 563 | .into(), |
| 567 | _ => error.to_string(), | |
| 564 | _ => crate::plain(&*error, "folder"), | |
| 568 | 565 | }); |
| 569 | 566 | } |
| 570 | 567 | // The folder's own name shows now. |
crates/snowbound/src/main.rs+71-2| ... | ... | @@ -3489,7 +3489,11 @@ impl State { |
| 3489 | 3489 | since: Instant::now(), |
| 3490 | 3490 | }))) |
| 3491 | 3491 | }); |
| 3492 | let _ = sender.send((id, laid.map_err(|error| error.to_string()))); | |
| 3492 | let laid = laid.map_err(|error| { | |
| 3493 | eprintln!("{error}"); | |
| 3494 | plain(&*error, "page") | |
| 3495 | }); | |
| 3496 | let _ = sender.send((id, laid)); | |
| 3493 | 3497 | redraw.wake(); |
| 3494 | 3498 | }); |
| 3495 | 3499 | } |
| ... | ... | @@ -3508,7 +3512,6 @@ impl State { |
| 3508 | 3512 | if id == self.loading { |
| 3509 | 3513 | self.switching = None; |
| 3510 | 3514 | } |
| 3511 | eprintln!("{error}"); | |
| 3512 | 3515 | platform::alert("Couldn't open", &error); |
| 3513 | 3516 | } |
| 3514 | 3517 | } |
| ... | ... | @@ -5766,6 +5769,51 @@ fn divider(ui: &mut Ui, theme: &Theme) { |
| 5766 | 5769 | ); |
| 5767 | 5770 | } |
| 5768 | 5771 | |
| 5772 | /// The kind of the file or network failure behind `error`, if one is. | |
| 5773 | pub fn io_kind(error: &(dyn Error + 'static)) -> Option<std::io::ErrorKind> { | |
| 5774 | let mut next = Some(error); | |
| 5775 | while let Some(error) = next { | |
| 5776 | if let Some(error) = error.downcast_ref::<std::io::Error>() { | |
| 5777 | return Some(error.kind()); | |
| 5778 | } | |
| 5779 | // Transparent variants forward `source` past the I/O error they hold. | |
| 5780 | if let Some(notebook::Error::Io(error) | notebook::Error::RemoteIo(error)) = | |
| 5781 | error.downcast_ref() | |
| 5782 | { | |
| 5783 | return Some(error.kind()); | |
| 5784 | } | |
| 5785 | next = error.source(); | |
| 5786 | } | |
| 5787 | None | |
| 5788 | } | |
| 5789 | ||
| 5790 | /// `error` as an alert tells it about `thing`: a file or network failure in plain words, | |
| 5791 | /// never the system's; any other error as it reads. | |
| 5792 | pub fn plain(error: &(dyn Error + 'static), thing: &str) -> String { | |
| 5793 | use std::io::ErrorKind::*; | |
| 5794 | match io_kind(error) { | |
| 5795 | None | Some(Other) => error.to_string(), | |
| 5796 | Some(NotFound) => format!("This {thing} was moved or deleted."), | |
| 5797 | Some(PermissionDenied | ReadOnlyFilesystem) => { | |
| 5798 | format!("You don't have permission to use this {thing}.") | |
| 5799 | } | |
| 5800 | Some(StorageFull | QuotaExceeded) => { | |
| 5801 | "The disk is full. Free up space, then try again.".into() | |
| 5802 | } | |
| 5803 | Some(WouldBlock | ResourceBusy) => { | |
| 5804 | format!("This {thing} is in use. Try again in a moment.") | |
| 5805 | } | |
| 5806 | Some(AlreadyExists) => { | |
| 5807 | "Something with that name is already there. Choose another name.".into() | |
| 5808 | } | |
| 5809 | Some( | |
| 5810 | TimedOut | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected | |
| 5811 | | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe, | |
| 5812 | ) => "The server can't be reached. Check your connection, then try again.".into(), | |
| 5813 | Some(_) => format!("Something went wrong with this {thing}. Try again."), | |
| 5814 | } | |
| 5815 | } | |
| 5816 | ||
| 5769 | 5817 | /// The session for `section`, at catalog `path` in `library`, showing `space` or its first |
| 5770 | 5818 | /// page, and that page. |
| 5771 | 5819 | fn read_session( |
| ... | ... | @@ -6636,6 +6684,27 @@ mod tests { |
| 6636 | 6684 | use super::*; |
| 6637 | 6685 | use canvas::document::TextPosition; |
| 6638 | 6686 | |
| 6687 | /// Alerts tell a file or network failure in plain words, however deep it is wrapped, | |
| 6688 | /// and pass the app's own messages on. | |
| 6689 | #[test] | |
| 6690 | fn alerts_never_show_the_systems_words() { | |
| 6691 | use std::io::{Error as Io, ErrorKind}; | |
| 6692 | let missing: Box<dyn Error> = Box::new(Io::from(ErrorKind::NotFound)); | |
| 6693 | assert_eq!(plain(&*missing, "page"), "This page was moved or deleted."); | |
| 6694 | let wrapped: Box<dyn Error> = Box::new(notebook::Error::Io(Io::from_raw_os_error(2))); | |
| 6695 | assert_eq!(plain(&*wrapped, "page"), "This page was moved or deleted."); | |
| 6696 | let discovered = notebook::discover::Error::Io { | |
| 6697 | path: "/a".into(), | |
| 6698 | error: Io::from(ErrorKind::TimedOut), | |
| 6699 | }; | |
| 6700 | assert!(plain(&discovered, "notebook").starts_with("The server can't be reached.")); | |
| 6701 | let refused: Box<dyn Error> = "The section is password protected".into(); | |
| 6702 | assert_eq!( | |
| 6703 | plain(&*refused, "page"), | |
| 6704 | "The section is password protected" | |
| 6705 | ); | |
| 6706 | } | |
| 6707 | ||
| 6639 | 6708 | /// A notebook opened from the desktop is listed once and shown first, whether it was |
| 6640 | 6709 | /// listed already or not. |
| 6641 | 6710 | #[test] |
crates/snowbound/src/manage.rs+9-3| ... | ... | @@ -554,7 +554,10 @@ impl State { |
| 554 | 554 | None => Ok(()), |
| 555 | 555 | }); |
| 556 | 556 | if let Err(error) = closed { |
| 557 | return platform::alert("Couldn't rename the folder", &error.to_string()); | |
| 557 | return platform::alert( | |
| 558 | "Couldn't rename the folder", | |
| 559 | &crate::plain(&*error, "section"), | |
| 560 | ); | |
| 558 | 561 | } |
| 559 | 562 | } |
| 560 | 563 | let showing = shown.is_some() || self.sectionless.as_ref().is_some_and(ours); |
| ... | ... | @@ -584,7 +587,7 @@ impl State { |
| 584 | 587 | }); |
| 585 | 588 | match colored { |
| 586 | 589 | Ok(colored) => reopened = colored, |
| 587 | Err(error) => problem = Some(error.to_string()), | |
| 590 | Err(error) => problem = Some(crate::plain(&*error, "notebook")), | |
| 588 | 591 | } |
| 589 | 592 | } |
| 590 | 593 | let reopened = Arc::new(reopened); |
| ... | ... | @@ -651,7 +654,10 @@ impl State { |
| 651 | 654 | Structure::NewSection { .. } => match self.dated_page(None) { |
| 652 | 655 | Ok(page) => Some(page), |
| 653 | 656 | Err(error) => { |
| 654 | return platform::alert("Couldn't add the section", &error.to_string()); | |
| 657 | return platform::alert( | |
| 658 | "Couldn't add the section", | |
| 659 | &crate::plain(&*error, "section"), | |
| 660 | ); | |
| 655 | 661 | } |
| 656 | 662 | }, |
| 657 | 663 | _ => None, |
crates/snowbound/src/navigation.rs+296-70| ... | ... | @@ -15,12 +15,36 @@ pub struct Place { |
| 15 | 15 | pub page: ExGuid, |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | /// A page visited, as Back and Forward find it again after its section is renamed or moved: | |
| 19 | /// its notebook's location, its section's file identity, and the page within it, by space | |
| 20 | /// and by the identity it keeps moving to another section. | |
| 21 | #[derive(Clone, Debug, PartialEq)] | |
| 22 | pub struct Visit { | |
| 23 | notebook: String, | |
| 24 | section: [u8; 16], | |
| 25 | space: ExGuid, | |
| 26 | page: Option<[u8; 16]>, | |
| 27 | /// Whether the section was in the recycle bin, where a deleted section keeps its identity. | |
| 28 | binned: bool, | |
| 29 | } | |
| 30 | ||
| 31 | impl Visit { | |
| 32 | /// Whether `other` is this page, wherever it has moved since. | |
| 33 | fn same(&self, other: &Visit) -> bool { | |
| 34 | self.notebook == other.notebook | |
| 35 | && match (self.page, other.page) { | |
| 36 | (Some(page), Some(other)) => page == other, | |
| 37 | _ => self.section == other.section && self.space == other.space, | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 18 | 42 | /// Pages visited before and after the one shown, and those shown lately. |
| 19 | 43 | #[derive(Default)] |
| 20 | 44 | pub struct Trail { |
| 21 | back: Vec<Place>, | |
| 22 | here: Option<Place>, | |
| 23 | forward: Vec<Place>, | |
| 45 | back: Vec<Visit>, | |
| 46 | here: Option<Visit>, | |
| 47 | forward: Vec<Visit>, | |
| 24 | 48 | /// Each page shown lately once, latest first, kept between launches. |
| 25 | 49 | pub recent: Vec<Place>, |
| 26 | 50 | } |
| ... | ... | @@ -33,25 +57,33 @@ const RECENT: usize = 8; |
| 33 | 57 | impl Trail { |
| 34 | 58 | /// Follows the notebook at `from` to `to`, where it moved. |
| 35 | 59 | pub fn moved(&mut self, from: &str, to: &str) { |
| 36 | let places = (self.back.iter_mut()) | |
| 60 | let visits = (self.back.iter_mut()) | |
| 37 | 61 | .chain(&mut self.here) |
| 38 | 62 | .chain(&mut self.forward) |
| 39 | .chain(&mut self.recent); | |
| 40 | for place in places.filter(|place| place.notebook == from) { | |
| 41 | place.notebook = to.to_owned(); | |
| 63 | .map(|visit| &mut visit.notebook); | |
| 64 | let places = self.recent.iter_mut().map(|place| &mut place.notebook); | |
| 65 | for notebook in visits.chain(places).filter(|notebook| *notebook == from) { | |
| 66 | *notebook = to.to_owned(); | |
| 42 | 67 | } |
| 43 | 68 | } |
| 44 | 69 | |
| 45 | /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages | |
| 46 | /// ahead, as a browser does. | |
| 47 | pub fn visit(&mut self, place: Place) { | |
| 70 | /// Notes `place` shown, as the recent pages list it. | |
| 71 | pub fn remember(&mut self, place: Place) { | |
| 48 | 72 | self.recent.retain(|recent| *recent != place); |
| 49 | self.recent.insert(0, place.clone()); | |
| 73 | self.recent.insert(0, place); | |
| 50 | 74 | self.recent.truncate(RECENT); |
| 51 | if self.here.as_ref() == Some(&place) { | |
| 75 | } | |
| 76 | ||
| 77 | /// Notes `visit` shown. Arriving anywhere but where Back or Forward went drops the pages | |
| 78 | /// ahead, as a browser does. | |
| 79 | pub fn visit(&mut self, visit: Visit) { | |
| 80 | if let Some(here) = &mut self.here | |
| 81 | && here.same(&visit) | |
| 82 | { | |
| 83 | *here = visit; | |
| 52 | 84 | return; |
| 53 | 85 | } |
| 54 | if let Some(here) = self.here.replace(place) { | |
| 86 | if let Some(here) = self.here.replace(visit) { | |
| 55 | 87 | self.back.push(here); |
| 56 | 88 | if self.back.len() > KEPT { |
| 57 | 89 | self.back.remove(0); |
| ... | ... | @@ -60,24 +92,26 @@ impl Trail { |
| 60 | 92 | self.forward.clear(); |
| 61 | 93 | } |
| 62 | 94 | |
| 63 | /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it | |
| 64 | /// refuses; the place to show. | |
| 65 | pub fn step(&mut self, forward: bool, exists: impl Fn(&Place) -> bool) -> Option<Place> { | |
| 95 | /// Steps back, or `forward`, to the nearest place `exists` accepts; the place to show. | |
| 96 | /// Places it refuses, on either side, are dropped. | |
| 97 | pub fn step(&mut self, forward: bool, exists: impl Fn(&Visit) -> bool) -> Option<Visit> { | |
| 98 | self.back.retain(&exists); | |
| 99 | self.forward.retain(&exists); | |
| 66 | 100 | let (from, to) = if forward { |
| 67 | 101 | (&mut self.forward, &mut self.back) |
| 68 | 102 | } else { |
| 69 | 103 | (&mut self.back, &mut self.forward) |
| 70 | 104 | }; |
| 71 | let place = std::iter::from_fn(|| from.pop()).find(|place| exists(place))?; | |
| 72 | if let Some(here) = self.here.replace(place.clone()) { | |
| 105 | let visit = from.pop()?; | |
| 106 | if let Some(here) = self.here.replace(visit.clone()) { | |
| 73 | 107 | to.push(here); |
| 74 | 108 | } |
| 75 | Some(place) | |
| 109 | Some(visit) | |
| 76 | 110 | } |
| 77 | 111 | |
| 78 | /// Whether Back and Forward have somewhere to go. | |
| 79 | pub fn open(&self) -> [bool; 2] { | |
| 80 | [!self.back.is_empty(), !self.forward.is_empty()] | |
| 112 | /// Whether Back and Forward have somewhere `exists` accepts to go. | |
| 113 | pub fn open(&self, exists: impl Fn(&Visit) -> bool) -> [bool; 2] { | |
| 114 | [&self.back, &self.forward].map(|side| side.iter().rev().any(&exists)) | |
| 81 | 115 | } |
| 82 | 116 | |
| 83 | 117 | /// Drops the recent pages whose section `listed` refuses, and those whose section `index` |
| ... | ... | @@ -98,24 +132,90 @@ impl Trail { |
| 98 | 132 | } |
| 99 | 133 | } |
| 100 | 134 | |
| 135 | /// Where `visit`'s page is in `notebooks`, as `index` has read them and `open` tells whether | |
| 136 | /// the section open, by key, lists a page: the notebook, the section's catalog path and the | |
| 137 | /// page. A page gone from its section is followed by its identity; none once it is nowhere. | |
| 138 | fn find( | |
| 139 | visit: &Visit, | |
| 140 | notebooks: &[Arc<Library>], | |
| 141 | open: impl Fn(&str, ExGuid) -> Option<bool>, | |
| 142 | index: &Index, | |
| 143 | ) -> Option<(Arc<Library>, String, ExGuid)> { | |
| 144 | let library = (notebooks.iter()).find(|library| library.location == visit.notebook)?; | |
| 145 | let path = match &library.notebook { | |
| 146 | Ok(Some(notebook)) => (crate::library::folders(notebook.catalog(), |_| true).into_iter()) | |
| 147 | .flat_map(|folder| &folder.sections) | |
| 148 | .find(|section| section.file_id == visit.section) | |
| 149 | .map(|section| section.path.clone()), | |
| 150 | Ok(None) => Some(library.location.clone()), | |
| 151 | Err(_) => None, | |
| 152 | } | |
| 153 | .filter(|path| crate::recycle::binned(path) == visit.binned); | |
| 154 | if let Some(path) = path { | |
| 155 | let key = library.key(&path); | |
| 156 | // A section the index has yet to read is taken to hold the page. | |
| 157 | let listed = open(&key, visit.space).unwrap_or_else(|| { | |
| 158 | index.get(&key, visit.space).is_some() | |
| 159 | || !index.entries().iter().any(|entry| entry.section == key) | |
| 160 | }); | |
| 161 | if listed { | |
| 162 | return Some((Arc::clone(library), path, visit.space)); | |
| 163 | } | |
| 164 | } | |
| 165 | let page = visit.page?; | |
| 166 | index.entries().iter().find_map(|entry| { | |
| 167 | let (location, path) = entry.section.split_once('\n')?; | |
| 168 | (entry.identity == Some(page) && location == visit.notebook) | |
| 169 | .then(|| (Arc::clone(library), path.to_owned(), entry.space)) | |
| 170 | }) | |
| 171 | } | |
| 172 | ||
| 101 | 173 | impl State { |
| 102 | 174 | /// Notes the page just shown for Back and Forward, and among the recent pages. |
| 103 | 175 | pub(crate) fn visited(&mut self) { |
| 104 | 176 | let Some(session) = &self.session else { |
| 105 | 177 | return; |
| 106 | 178 | }; |
| 179 | let path = &session.tabs[session.tab].path; | |
| 107 | 180 | let place = Place { |
| 108 | 181 | notebook: session.library.location.clone(), |
| 109 | section: session.tabs[session.tab].path.clone(), | |
| 182 | section: path.clone(), | |
| 110 | 183 | page: session.space, |
| 111 | 184 | }; |
| 185 | if let Ok(section) = session.section.identity() { | |
| 186 | self.trail.visit(Visit { | |
| 187 | notebook: place.notebook.clone(), | |
| 188 | section, | |
| 189 | space: session.space, | |
| 190 | page: self.view.editor.identity(), | |
| 191 | binned: crate::recycle::binned(path), | |
| 192 | }); | |
| 193 | } | |
| 112 | 194 | let moved = self.trail.recent.first() != Some(&place); |
| 113 | self.trail.visit(place); | |
| 195 | self.trail.remember(place); | |
| 114 | 196 | if moved { |
| 115 | 197 | self.save_settings(); |
| 116 | 198 | } |
| 117 | 199 | } |
| 118 | 200 | |
| 201 | /// Where `visit`'s page is now, as `find` follows it. | |
| 202 | fn find(&self, visit: &Visit) -> Option<(Arc<Library>, String, ExGuid)> { | |
| 203 | let lists = |key: &str, page| { | |
| 204 | let session = self | |
| 205 | .session | |
| 206 | .as_ref() | |
| 207 | .filter(|session| session.key() == key)?; | |
| 208 | Some(session.pages.iter().any(|(space, ..)| *space == page)) | |
| 209 | }; | |
| 210 | let index = (self.search.index.lock()).unwrap_or_else(|poison| poison.into_inner()); | |
| 211 | find(visit, &self.notebooks, lists, &index) | |
| 212 | } | |
| 213 | ||
| 214 | /// Whether Back and Forward have a page to go to. | |
| 215 | pub(crate) fn can_travel(&self) -> [bool; 2] { | |
| 216 | self.trail.open(|visit| self.find(visit).is_some()) | |
| 217 | } | |
| 218 | ||
| 119 | 219 | /// Whether `library`'s section at `path` is the one open. |
| 120 | 220 | pub(crate) fn open(&self, library: &Library, path: &str) -> bool { |
| 121 | 221 | self.session.as_ref().is_some_and(|session| { |
| ... | ... | @@ -134,28 +234,15 @@ impl State { |
| 134 | 234 | } |
| 135 | 235 | } |
| 136 | 236 | |
| 137 | /// Shows the page visited before the one shown, or after it going `forward`. A page | |
| 138 | /// deleted from the open section since is passed over. | |
| 237 | /// Shows the page visited before the one shown, or after it going `forward`, wherever it | |
| 238 | /// went since. Pages since deleted, and those of notebooks since closed, are passed over. | |
| 139 | 239 | pub(crate) fn travel(&mut self, forward: bool) { |
| 140 | let session = self.session.as_ref(); | |
| 141 | let exists = |place: &Place| { | |
| 142 | session.is_none_or(|session| { | |
| 143 | session.library.location != place.notebook | |
| 144 | || session.tabs[session.tab].path != place.section | |
| 145 | || session.pages.iter().any(|(page, ..)| *page == place.page) | |
| 146 | }) | |
| 147 | }; | |
| 148 | let Some(place) = self.trail.step(forward, exists) else { | |
| 149 | return; | |
| 150 | }; | |
| 151 | let Some(library) = self | |
| 152 | .notebooks | |
| 153 | .iter() | |
| 154 | .find(|library| library.location == place.notebook) | |
| 155 | else { | |
| 156 | return; | |
| 157 | }; | |
| 158 | self.go(Arc::clone(library), place.section, place.page); | |
| 240 | let mut trail = std::mem::take(&mut self.trail); | |
| 241 | let visit = trail.step(forward, |visit| self.find(visit).is_some()); | |
| 242 | self.trail = trail; | |
| 243 | if let Some((library, path, space)) = visit.and_then(|visit| self.find(&visit)) { | |
| 244 | self.go(library, path, space); | |
| 245 | } | |
| 159 | 246 | } |
| 160 | 247 | } |
| 161 | 248 | |
| ... | ... | @@ -240,53 +327,95 @@ mod tests { |
| 240 | 327 | assert_eq!(stroke(&mut swipe, 200.0, 0.0, false, 4000), None); |
| 241 | 328 | } |
| 242 | 329 | |
| 330 | fn visit(section: u8, page: u32) -> Visit { | |
| 331 | let mut identity = [0; 16]; | |
| 332 | identity[0] = section; | |
| 333 | identity[1..5].copy_from_slice(&page.to_le_bytes()); | |
| 334 | Visit { | |
| 335 | notebook: "/notebooks/Personal".into(), | |
| 336 | section: [section; 16], | |
| 337 | space: ExGuid { | |
| 338 | guid: [7; 16], | |
| 339 | n: page, | |
| 340 | }, | |
| 341 | page: Some(identity), | |
| 342 | binned: false, | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 243 | 346 | #[test] |
| 244 | 347 | fn back_and_forward_retrace_visits_across_sections() { |
| 245 | 348 | let mut trail = Trail::default(); |
| 246 | assert_eq!(trail.open(), [false, false]); | |
| 247 | for (section, page) in [("A.one", 1), ("A.one", 2), ("B.one", 1)] { | |
| 248 | trail.visit(place(section, page)); | |
| 349 | let all = |_: &Visit| true; | |
| 350 | assert_eq!(trail.open(all), [false, false]); | |
| 351 | for (section, page) in [(1, 1), (1, 2), (2, 1)] { | |
| 352 | trail.visit(visit(section, page)); | |
| 249 | 353 | } |
| 250 | 354 | // Showing the same page again, as a reload does, is no visit. |
| 251 | trail.visit(place("B.one", 1)); | |
| 252 | let all = |_: &Place| true; | |
| 253 | assert_eq!(trail.step(false, all), Some(place("A.one", 2))); | |
| 355 | trail.visit(visit(2, 1)); | |
| 356 | assert_eq!(trail.step(false, all), Some(visit(1, 2))); | |
| 254 | 357 | // Arriving where Back went is not a new visit. |
| 255 | trail.visit(place("A.one", 2)); | |
| 256 | assert_eq!(trail.open(), [true, true]); | |
| 257 | assert_eq!(trail.step(false, all), Some(place("A.one", 1))); | |
| 358 | trail.visit(visit(1, 2)); | |
| 359 | assert_eq!(trail.open(all), [true, true]); | |
| 360 | assert_eq!(trail.step(false, all), Some(visit(1, 1))); | |
| 258 | 361 | assert_eq!(trail.step(false, all), None); |
| 259 | assert_eq!(trail.step(true, all), Some(place("A.one", 2))); | |
| 260 | assert_eq!(trail.step(true, all), Some(place("B.one", 1))); | |
| 362 | assert_eq!(trail.step(true, all), Some(visit(1, 2))); | |
| 363 | assert_eq!(trail.step(true, all), Some(visit(2, 1))); | |
| 261 | 364 | assert_eq!(trail.step(true, all), None); |
| 262 | 365 | // A page opened after going back drops the pages ahead. |
| 263 | 366 | trail.step(false, all); |
| 264 | trail.visit(place("C.one", 5)); | |
| 265 | assert_eq!(trail.open(), [true, false]); | |
| 266 | assert_eq!(trail.step(false, all), Some(place("A.one", 2))); | |
| 367 | trail.visit(visit(3, 5)); | |
| 368 | assert_eq!(trail.open(all), [true, false]); | |
| 369 | assert_eq!(trail.step(false, all), Some(visit(1, 2))); | |
| 267 | 370 | } |
| 268 | 371 | |
| 269 | 372 | #[test] |
| 270 | fn back_passes_over_pages_since_deleted() { | |
| 373 | fn back_and_forward_pass_over_pages_gone_and_go_dark_without_any() { | |
| 271 | 374 | let mut trail = Trail::default(); |
| 272 | for page in 1..=3 { | |
| 273 | trail.visit(place("A.one", page)); | |
| 375 | for page in 1..=4 { | |
| 376 | trail.visit(visit(1, page)); | |
| 274 | 377 | } |
| 275 | let kept = |place: &Place| place.page.n != 2; | |
| 276 | assert_eq!(trail.step(false, kept), Some(place("A.one", 1))); | |
| 277 | assert_eq!(trail.step(true, kept), Some(place("A.one", 3))); | |
| 278 | assert_eq!(trail.open(), [true, false]); | |
| 378 | let all = |_: &Visit| true; | |
| 379 | trail.step(false, all); | |
| 380 | // Pages 1 and 2 behind, 4 ahead; 2 and 4 are gone since. | |
| 381 | let kept = |visit: &Visit| ![2, 4].contains(&visit.space.n); | |
| 382 | assert_eq!(trail.open(kept), [true, false]); | |
| 383 | assert_eq!(trail.step(true, kept), None); | |
| 384 | assert_eq!(trail.step(false, kept), Some(visit(1, 1))); | |
| 385 | // What was passed over is gone for good. | |
| 386 | assert_eq!(trail.step(true, all), Some(visit(1, 3))); | |
| 387 | assert_eq!(trail.open(all), [true, false]); | |
| 388 | } | |
| 389 | ||
| 390 | #[test] | |
| 391 | fn arriving_at_a_page_moved_since_is_no_new_visit() { | |
| 392 | let mut trail = Trail::default(); | |
| 393 | for page in 1..=2 { | |
| 394 | trail.visit(visit(1, page)); | |
| 395 | } | |
| 396 | let all = |_: &Visit| true; | |
| 397 | trail.step(false, all); | |
| 398 | // Page 1 now shows from section 2, under a new space, as Back found it. | |
| 399 | let moved = Visit { | |
| 400 | section: [2; 16], | |
| 401 | space: visit(2, 9).space, | |
| 402 | ..visit(1, 1) | |
| 403 | }; | |
| 404 | trail.visit(moved.clone()); | |
| 405 | assert_eq!(trail.open(all), [false, true]); | |
| 406 | assert_eq!(trail.step(true, all), Some(visit(1, 2))); | |
| 407 | assert_eq!(trail.step(false, all), Some(moved)); | |
| 279 | 408 | } |
| 280 | 409 | |
| 281 | 410 | #[test] |
| 282 | 411 | fn recent_pages_are_kept_once_latest_first_and_bounded() { |
| 283 | 412 | let mut trail = Trail::default(); |
| 284 | 413 | for page in [1, 2, 1] { |
| 285 | trail.visit(place("A.one", page)); | |
| 414 | trail.remember(place("A.one", page)); | |
| 286 | 415 | } |
| 287 | 416 | assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]); |
| 288 | 417 | for page in 0..20 { |
| 289 | trail.visit(place("B.one", page)); | |
| 418 | trail.remember(place("B.one", page)); | |
| 290 | 419 | } |
| 291 | 420 | assert_eq!(trail.recent.len(), RECENT); |
| 292 | 421 | assert_eq!(trail.recent[0], place("B.one", 19)); |
| ... | ... | @@ -322,7 +451,7 @@ mod tests { |
| 322 | 451 | place("A.one", 2), |
| 323 | 452 | kept.clone(), |
| 324 | 453 | ] { |
| 325 | trail.visit(gone); | |
| 454 | trail.remember(gone); | |
| 326 | 455 | } |
| 327 | 456 | let listed = |place: &Place| place.section != "C.one"; |
| 328 | 457 | assert!(trail.prune(&index, listed)); |
| ... | ... | @@ -334,7 +463,7 @@ mod tests { |
| 334 | 463 | fn back_keeps_its_last_hundred_places() { |
| 335 | 464 | let mut trail = Trail::default(); |
| 336 | 465 | for page in 0..150 { |
| 337 | trail.visit(place("A.one", page)); | |
| 466 | trail.visit(visit(1, page)); | |
| 338 | 467 | } |
| 339 | 468 | let mut steps = 0; |
| 340 | 469 | while trail.step(false, |_| true).is_some() { |
| ... | ... | @@ -342,4 +471,101 @@ mod tests { |
| 342 | 471 | } |
| 343 | 472 | assert_eq!(steps, KEPT); |
| 344 | 473 | } |
| 474 | ||
| 475 | /// Back and Forward find a page by its section's identity and its own, in the cases the | |
| 476 | /// path it was visited at no longer opens: the section renamed, moved into a group or | |
| 477 | /// deleted, the page moved or deleted, the notebook closed. | |
| 478 | #[test] | |
| 479 | fn visits_are_found_wherever_their_section_and_page_went() { | |
| 480 | use notebook::session::Notebook; | |
| 481 | use onestore::PageCreation; | |
| 482 | let temporary = | |
| 483 | std::env::temp_dir().join(format!("snowbound-visits-{}", std::process::id())); | |
| 484 | let _ = notebook::fs::remove_dir_all(&temporary); | |
| 485 | notebook::fs::create_dir_all(&temporary).unwrap(); | |
| 486 | let root = temporary.join("Visited"); | |
| 487 | let location = root.to_str().unwrap(); | |
| 488 | let cache = temporary.join("cache"); | |
| 489 | let dated = || PageCreation::new(None, Some(""), "Author").unwrap(); | |
| 490 | let mut notebook = | |
| 491 | Notebook::create(location, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); | |
| 492 | notebook.create_section("", "A", &dated()).unwrap(); | |
| 493 | notebook.create_section("", "B", &dated()).unwrap(); | |
| 494 | let mut library = Arc::new(Library::created(location, notebook, &cache)); | |
| 495 | let a = library.section_identity("A.one").unwrap(); | |
| 496 | let page = |n| ExGuid { guid: [9; 16], n }; | |
| 497 | let shown = Visit { | |
| 498 | notebook: location.to_owned(), | |
| 499 | section: a, | |
| 500 | space: page(1), | |
| 501 | page: Some([5; 16]), | |
| 502 | binned: false, | |
| 503 | }; | |
| 504 | let mut index = Index::default(); | |
| 505 | let found = |library: &Arc<Library>, index: &Index, open: Option<&str>| { | |
| 506 | let lists = |key: &str, space| (Some(key) == open).then_some(space == page(2)); | |
| 507 | find(&shown, std::slice::from_ref(library), lists, index) | |
| 508 | .map(|(_, path, space)| (path, space.n)) | |
| 509 | }; | |
| 510 | assert_eq!(found(&library, &index, None), Some(("A.one".into(), 1))); | |
| 511 | ||
| 512 | let change = |library: &mut Arc<Library>, change: &dyn Fn(&mut Notebook)| { | |
| 513 | let mut notebook = library.reopen().unwrap(); | |
| 514 | change(&mut notebook); | |
| 515 | *library = Arc::new(library.with(notebook)); | |
| 516 | }; | |
| 517 | change(&mut library, &|notebook| { | |
| 518 | drop(notebook.rename("A.one", "Renamed").unwrap()) | |
| 519 | }); | |
| 520 | assert_eq!( | |
| 521 | found(&library, &index, None), | |
| 522 | Some(("Renamed.one".into(), 1)) | |
| 523 | ); | |
| 524 | change(&mut library, &|notebook| { | |
| 525 | notebook.create_group("", "Group").unwrap(); | |
| 526 | notebook.move_entry("Renamed.one", "Group").unwrap(); | |
| 527 | }); | |
| 528 | let moved = "Group/Renamed.one"; | |
| 529 | assert_eq!(found(&library, &index, None), Some((moved.into(), 1))); | |
| 530 | ||
| 531 | // The open section's own list, and the index's of others, tell a page deleted. | |
| 532 | let key = library.key(moved); | |
| 533 | let open = Some(key.as_str()); | |
| 534 | assert_eq!(found(&library, &index, open), None); | |
| 535 | let entry = |path: &str, space, identity| { | |
| 536 | let mut page = onestore::page::Page { | |
| 537 | title: String::new(), | |
| 538 | identity: None, | |
| 539 | created: None, | |
| 540 | margin_origin: [0.0; 2], | |
| 541 | rtl: false, | |
| 542 | color: None, | |
| 543 | rule_lines: None, | |
| 544 | objects: Vec::new(), | |
| 545 | definitions: Default::default(), | |
| 546 | }; | |
| 547 | page.identity = identity; | |
| 548 | canvas::search::Entry::new(&library.key(path), space, &page, 0) | |
| 549 | }; | |
| 550 | index.set(entry(moved, page(2), None)); | |
| 551 | assert_eq!(found(&library, &index, None), None); | |
| 552 | // A page moved to another section is followed by its identity. | |
| 553 | index.set(entry("B.one", page(7), Some([5; 16]))); | |
| 554 | assert_eq!(found(&library, &index, None), Some(("B.one".into(), 7))); | |
| 555 | assert_eq!(found(&library, &index, open), Some(("B.one".into(), 7))); | |
| 556 | ||
| 557 | // A section deleted to the recycle bin keeps its identity there. | |
| 558 | let index = Index::default(); | |
| 559 | change(&mut library, &|notebook| notebook.delete(moved).unwrap()); | |
| 560 | let notebooks = [library]; | |
| 561 | assert!( | |
| 562 | find(&shown, &notebooks, |_, _| None, &index).is_none(), | |
| 563 | "the section deleted" | |
| 564 | ); | |
| 565 | assert!( | |
| 566 | find(&shown, &[], |_, _| None, &index).is_none(), | |
| 567 | "the notebook closed" | |
| 568 | ); | |
| 569 | notebook::fs::remove_dir_all(&temporary).unwrap(); | |
| 570 | } | |
| 345 | 571 | } |
crates/snowbound/src/print.rs+2-2| ... | ... | @@ -273,7 +273,7 @@ impl State { |
| 273 | 273 | if go { |
| 274 | 274 | self.printing.last = Some(setup); |
| 275 | 275 | if let Err(error) = self.print(setup, export) { |
| 276 | platform::alert("Couldn't print", &error.to_string()); | |
| 276 | platform::alert("Couldn't print", &crate::plain(&*error, "page")); | |
| 277 | 277 | } |
| 278 | 278 | } |
| 279 | 279 | } |
| ... | ... | @@ -379,7 +379,7 @@ impl State { |
| 379 | 379 | } else { |
| 380 | 380 | "Couldn't print" |
| 381 | 381 | }; |
| 382 | let done = done.map_err(|error| error.to_string()); | |
| 382 | let done = done.map_err(|error| crate::plain(&*error, "file")); | |
| 383 | 383 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| { |
| 384 | 384 | match done { |
| 385 | 385 | Ok(Some(pdf)) => printer::print(&state.window, &pdf, &title), |
crates/snowbound/src/protection.rs+8-2| ... | ... | @@ -540,7 +540,10 @@ impl State { |
| 540 | 540 | Some(Dialog::Unlock(Zeroizing::default(), true)) |
| 541 | 541 | } |
| 542 | 542 | Err(error) => { |
| 543 | crate::platform::alert("Couldn't unlock the section", &error.to_string()); | |
| 543 | crate::platform::alert( | |
| 544 | "Couldn't unlock the section", | |
| 545 | &crate::plain(&error, "section"), | |
| 546 | ); | |
| 544 | 547 | None |
| 545 | 548 | } |
| 546 | 549 | }, |
| ... | ... | @@ -622,7 +625,10 @@ impl State { |
| 622 | 625 | Ok(()) |
| 623 | 626 | }); |
| 624 | 627 | if let Err(error) = closed { |
| 625 | return crate::platform::alert("Couldn't set the password", &error.to_string()); | |
| 628 | return crate::platform::alert( | |
| 629 | "Couldn't set the password", | |
| 630 | &crate::plain(&*error, "section"), | |
| 631 | ); | |
| 626 | 632 | } |
| 627 | 633 | self.prefetch.forget(&library.key(&path)); |
| 628 | 634 | } |
crates/snowbound/src/rename.rs+4-1| ... | ... | @@ -115,7 +115,10 @@ impl State { |
| 115 | 115 | } |
| 116 | 116 | Target::Page(space) => { |
| 117 | 117 | if let Err(error) = self.retitle(space, name) { |
| 118 | crate::platform::alert("Couldn't rename the page", &error.to_string()); | |
| 118 | crate::platform::alert( | |
| 119 | "Couldn't rename the page", | |
| 120 | &crate::plain(&*error, "page"), | |
| 121 | ); | |
| 119 | 122 | } |
| 120 | 123 | } |
| 121 | 124 | } |
crates/snowbound/src/save_as.rs+2-2| ... | ... | @@ -144,7 +144,7 @@ impl State { |
| 144 | 144 | let dialog = self.save_as.take().expect("The dialog is open"); |
| 145 | 145 | self.ui.close_popup(id()); |
| 146 | 146 | if go && let Err(error) = self.save(dialog) { |
| 147 | platform::alert("Couldn't save", &error.to_string()); | |
| 147 | platform::alert("Couldn't save", &crate::plain(&*error, "file")); | |
| 148 | 148 | } |
| 149 | 149 | } |
| 150 | 150 | |
| ... | ... | @@ -213,7 +213,7 @@ impl State { |
| 213 | 213 | notebook::fs::write(&path, bytes)?; |
| 214 | 214 | Ok(()) |
| 215 | 215 | })(); |
| 216 | let written = written.map_err(|error| error.to_string()); | |
| 216 | let written = written.map_err(|error| crate::plain(&*error, "file")); | |
| 217 | 217 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { |
| 218 | 218 | written.map_err(|error| { |
| 219 | 219 | platform::alert("Couldn't save", &error); |
crates/snowbound/src/tags.rs+4-1| ... | ... | @@ -621,7 +621,10 @@ impl State { |
| 621 | 621 | } |
| 622 | 622 | }); |
| 623 | 623 | if let Err(error) = written { |
| 624 | return platform::alert("Couldn't keep the picture", &error.to_string()); | |
| 624 | return platform::alert( | |
| 625 | "Couldn't keep the picture", | |
| 626 | &crate::plain(&error, "picture"), | |
| 627 | ); | |
| 625 | 628 | } |
| 626 | 629 | tag.art = Some(art); |
| 627 | 630 | if tag.shape == 0 { |
crates/snowbound/src/unpack.rs+1-1| ... | ... | @@ -233,7 +233,7 @@ impl State { |
| 233 | 233 | } |
| 234 | 234 | Ok(location) |
| 235 | 235 | })() |
| 236 | .map_err(|error| error.to_string()); | |
| 236 | .map_err(|error| crate::plain(&*error, "package")); | |
| 237 | 237 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { |
| 238 | 238 | match unpacked { |
| 239 | 239 | Ok(location) => state.open_notebook(location, None), |
crates/snowbound/src/web.rs+1-1| ... | ... | @@ -612,7 +612,7 @@ pub fn open_file(path: &Path) { |
| 612 | 612 | &bytes, |
| 613 | 613 | "application/octet-stream", |
| 614 | 614 | ), |
| 615 | Err(error) => alert("Couldn't open the file", &error.to_string()), | |
| 615 | Err(error) => alert("Couldn't open the file", &crate::plain(&error, "file")), | |
| 616 | 616 | } |
| 617 | 617 | } |
| 618 | 618 |
crates/snowbound/tests/replay.rs+98| ... | ... | @@ -274,6 +274,104 @@ fn undo_walks_back_through_new_pages_and_their_titles() { |
| 274 | 274 | } |
| 275 | 275 | } |
| 276 | 276 | |
| 277 | /// Whether `tree`'s toolbar offers Back and Forward. | |
| 278 | fn travels(tree: &str) -> [bool; 2] { | |
| 279 | ["Back", "Forward"].map(|name| { | |
| 280 | tree.lines() | |
| 281 | .find(|line| line.trim_start().starts_with(&format!("Button \"{name}\""))) | |
| 282 | .is_some_and(|line| !line.ends_with("[disabled]")) | |
| 283 | }) | |
| 284 | } | |
| 285 | ||
| 286 | const BACK: [&str; 4] = [ | |
| 287 | "modifiers command control", | |
| 288 | "key Left", | |
| 289 | "modifiers", | |
| 290 | "settle", | |
| 291 | ]; | |
| 292 | const FORWARD: [&str; 4] = [ | |
| 293 | "modifiers command control", | |
| 294 | "key Right", | |
| 295 | "modifiers", | |
| 296 | "settle", | |
| 297 | ]; | |
| 298 | const NEW_PAGE: [&str; 4] = ["modifiers command", "key n", "modifiers", "settle"]; | |
| 299 | ||
| 300 | /// Forward reaches a page whose section was renamed after going Back from it, where the | |
| 301 | /// path it was visited at no longer opens. | |
| 302 | #[test] | |
| 303 | fn forward_follows_a_section_renamed_since() { | |
| 304 | let scratch = Scratch::new("forward-renamed"); | |
| 305 | let notebook = | |
| 306 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 307 | let mut steps = Vec::from(NEW_PAGE); | |
| 308 | steps.push("type Two"); | |
| 309 | steps.extend(BACK); | |
| 310 | // The section's tab menu, Rename. | |
| 311 | steps.extend(["move 110 53", "press right", "release right", "settle"]); | |
| 312 | steps.extend([ | |
| 313 | "key Down", | |
| 314 | "key Enter", | |
| 315 | "settle", | |
| 316 | "type Renamed", | |
| 317 | "key Enter", | |
| 318 | "settle", | |
| 319 | ]); | |
| 320 | steps.push("accessibility renamed"); | |
| 321 | steps.extend(FORWARD); | |
| 322 | steps.push("accessibility forward"); | |
| 323 | let [renamed, forward] = replay(&scratch, Some(&notebook), &steps) | |
| 324 | .try_into() | |
| 325 | .unwrap(); | |
| 326 | assert!(scratch.0.join("notebook/Renamed.one").exists()); | |
| 327 | assert_eq!(travels(&renamed), [false, true], "{renamed}"); | |
| 328 | let shown = |tree: &str| page_tabs(tree).into_iter().find(|tab| tab.starts_with('*')); | |
| 329 | assert_eq!(shown(&forward).as_deref(), Some("*Two"), "{forward}"); | |
| 330 | assert!(forward.contains(r#"Tab "Renamed" [selected]"#), "{forward}"); | |
| 331 | assert_eq!(travels(&forward), [true, false], "{forward}"); | |
| 332 | } | |
| 333 | ||
| 334 | /// Back passes over a page deleted since it was shown, and goes dark once nothing is left | |
| 335 | /// behind. | |
| 336 | #[test] | |
| 337 | fn back_passes_over_a_page_deleted_since() { | |
| 338 | let scratch = Scratch::new("back-deleted"); | |
| 339 | let notebook = | |
| 340 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 341 | let mut steps = Vec::new(); | |
| 342 | for title in ["type Two", "type Three"] { | |
| 343 | steps.extend(NEW_PAGE); | |
| 344 | steps.push(title); | |
| 345 | } | |
| 346 | // Two's tab menu, Delete. | |
| 347 | steps.extend([ | |
| 348 | "settle", | |
| 349 | "move 1003 198", | |
| 350 | "press right", | |
| 351 | "release right", | |
| 352 | "settle", | |
| 353 | ]); | |
| 354 | steps.extend(["key Down", "key Down", "key Enter", "settle"]); | |
| 355 | steps.push("accessibility deleted"); | |
| 356 | steps.extend(BACK); | |
| 357 | steps.push("accessibility back"); | |
| 358 | let [deleted, back] = replay(&scratch, Some(&notebook), &steps) | |
| 359 | .try_into() | |
| 360 | .unwrap(); | |
| 361 | let shown = |tree: &str| page_tabs(tree).into_iter().find(|tab| tab.starts_with('*')); | |
| 362 | assert!( | |
| 363 | !page_tabs(&deleted).contains(&"Two".to_owned()), | |
| 364 | "{deleted}" | |
| 365 | ); | |
| 366 | assert_eq!(shown(&deleted).as_deref(), Some("*Three"), "{deleted}"); | |
| 367 | assert_eq!( | |
| 368 | shown(&back).as_deref(), | |
| 369 | Some("*Delete into a table"), | |
| 370 | "{back}" | |
| 371 | ); | |
| 372 | assert_eq!(travels(&back), [false, true], "{back}"); | |
| 373 | } | |
| 374 | ||
| 277 | 375 | /// The search box's results in `tree`, one a line. |
| 278 | 376 | fn search_results(tree: &str) -> Vec<&str> { |
| 279 | 377 | tree.lines() |