| 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,6 +285,8 @@ pub struct Entry { |
| 285 | /// The section's key, which the host chooses. | 285 | /// The section's key, which the host chooses. |
| 286 | pub section: String, | 286 | pub section: String, |
| 287 | pub space: ExGuid, | 287 | pub space: ExGuid, |
| 288 | /// The page's own identity, which it keeps moving to another section. | ||
| 289 | pub identity: Option<[u8; 16]>, | ||
| 288 | pub title: String, | 290 | pub title: String, |
| 289 | /// When the page last changed, in any unit that orders. | 291 | /// When the page last changed, in any unit that orders. |
| 290 | pub modified: u64, | 292 | pub modified: u64, |
| ... | @@ -301,6 +303,7 @@ impl Entry { | ... | @@ -301,6 +303,7 @@ impl Entry { |
| 301 | Self { | 303 | Self { |
| 302 | section: section.to_owned(), | 304 | section: section.to_owned(), |
| 303 | space, | 305 | space, |
| 306 | identity: page.identity, | ||
| 304 | folded_title: fold(&page.title), | 307 | folded_title: fold(&page.title), |
| 305 | folded_text: fold(&text), | 308 | folded_text: fold(&text), |
| 306 | title: page.title.clone(), | 309 | title: page.title.clone(), |
crates/snowbound/src/commands.rs+3-2| ... | @@ -1234,8 +1234,9 @@ impl State { | ... | @@ -1234,8 +1234,9 @@ impl State { |
| 1234 | Id::Copy => enabled(selected), | 1234 | Id::Copy => enabled(selected), |
| 1235 | Id::Paste => enabled(text && !field), | 1235 | Id::Paste => enabled(text && !field), |
| 1236 | Id::SelectAll => enabled(field || page), | 1236 | Id::SelectAll => enabled(field || page), |
| 1237 | Id::Back => enabled(!modal && self.trail.open()[0]), | 1237 | Id::Back | Id::Forward => { |
| 1238 | Id::Forward => enabled(!modal && self.trail.open()[1]), | 1238 | enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)]) |
| 1239 | } | ||
| 1239 | Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page), | 1240 | Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page), |
| 1240 | Id::Sidebar => Status { | 1241 | Id::Sidebar => Status { |
| 1241 | enabled: !welcome && !modal, | 1242 | enabled: !welcome && !modal, |
crates/snowbound/src/desktop_linux.rs+7-2| ... | @@ -435,7 +435,9 @@ pub fn install() { | ... | @@ -435,7 +435,9 @@ pub fn install() { |
| 435 | "To uninstall it, open Options from the notebook menu. Uninstall is under Updates.", | 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,7 +527,10 @@ fn remove() { |
| 525 | } | 527 | } |
| 526 | refresh_caches(&data); | 528 | refresh_caches(&data); |
| 527 | match failed { | 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 | None => *INSTALLED.lock().unwrap() = Installed::No, | 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,7 +409,10 @@ impl Library { |
| 409 | tag_art: Mutex::new(Arc::new( | 409 | tag_art: Mutex::new(Arc::new( |
| 410 | notebook.as_ref().map(read_tag_art).unwrap_or_default(), | 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 | notice, | 416 | notice, |
| 414 | ..Self::new(location, name, cache) | 417 | ..Self::new(location, name, cache) |
| 415 | } | 418 | } |
| ... | @@ -544,13 +547,7 @@ impl Library { | ... | @@ -544,13 +547,7 @@ impl Library { |
| 544 | .reopen() | 547 | .reopen() |
| 545 | .and_then(|notebook| Ok(notebook.rename_folder(name)?)); | 548 | .and_then(|notebook| Ok(notebook.rename_folder(name)?)); |
| 546 | if let Err(error) = renamed { | 549 | if let Err(error) = renamed { |
| 547 | let kind = match error.downcast_ref::<notebook::Error>() { | 550 | return Err(match crate::io_kind(&*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 { | ||
| 554 | Some(io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy) => { | 551 | Some(io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy) => { |
| 555 | "Another computer is saving to this notebook. Try again in a moment.".into() | 552 | "Another computer is saving to this notebook. Try again in a moment.".into() |
| 556 | } | 553 | } |
| ... | @@ -564,7 +561,7 @@ impl Library { | ... | @@ -564,7 +561,7 @@ impl Library { |
| 564 | Some(io::ErrorKind::InvalidInput) => "A folder name can’t contain \\ / : * ? \" \ | 561 | Some(io::ErrorKind::InvalidInput) => "A folder name can’t contain \\ / : * ? \" \ |
| 565 | < > | or end with a dot or space." | 562 | < > | or end with a dot or space." |
| 566 | .into(), | 563 | .into(), |
| 567 | _ => error.to_string(), | 564 | _ => crate::plain(&*error, "folder"), |
| 568 | }); | 565 | }); |
| 569 | } | 566 | } |
| 570 | // The folder's own name shows now. | 567 | // The folder's own name shows now. |
crates/snowbound/src/main.rs+71-2| ... | @@ -3489,7 +3489,11 @@ impl State { | ... | @@ -3489,7 +3489,11 @@ impl State { |
| 3489 | since: Instant::now(), | 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 | redraw.wake(); | 3497 | redraw.wake(); |
| 3494 | }); | 3498 | }); |
| 3495 | } | 3499 | } |
| ... | @@ -3508,7 +3512,6 @@ impl State { | ... | @@ -3508,7 +3512,6 @@ impl State { |
| 3508 | if id == self.loading { | 3512 | if id == self.loading { |
| 3509 | self.switching = None; | 3513 | self.switching = None; |
| 3510 | } | 3514 | } |
| 3511 | eprintln!("{error}"); | ||
| 3512 | platform::alert("Couldn't open", &error); | 3515 | platform::alert("Couldn't open", &error); |
| 3513 | } | 3516 | } |
| 3514 | } | 3517 | } |
| ... | @@ -5766,6 +5769,51 @@ fn divider(ui: &mut Ui, theme: &Theme) { | ... | @@ -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 | /// The session for `section`, at catalog `path` in `library`, showing `space` or its first | 5817 | /// The session for `section`, at catalog `path` in `library`, showing `space` or its first |
| 5770 | /// page, and that page. | 5818 | /// page, and that page. |
| 5771 | fn read_session( | 5819 | fn read_session( |
| ... | @@ -6636,6 +6684,27 @@ mod tests { | ... | @@ -6636,6 +6684,27 @@ mod tests { |
| 6636 | use super::*; | 6684 | use super::*; |
| 6637 | use canvas::document::TextPosition; | 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 | /// A notebook opened from the desktop is listed once and shown first, whether it was | 6708 | /// A notebook opened from the desktop is listed once and shown first, whether it was |
| 6640 | /// listed already or not. | 6709 | /// listed already or not. |
| 6641 | #[test] | 6710 | #[test] |
crates/snowbound/src/manage.rs+9-3| ... | @@ -554,7 +554,10 @@ impl State { | ... | @@ -554,7 +554,10 @@ impl State { |
| 554 | None => Ok(()), | 554 | None => Ok(()), |
| 555 | }); | 555 | }); |
| 556 | if let Err(error) = closed { | 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 | let showing = shown.is_some() || self.sectionless.as_ref().is_some_and(ours); | 563 | let showing = shown.is_some() || self.sectionless.as_ref().is_some_and(ours); |
| ... | @@ -584,7 +587,7 @@ impl State { | ... | @@ -584,7 +587,7 @@ impl State { |
| 584 | }); | 587 | }); |
| 585 | match colored { | 588 | match colored { |
| 586 | Ok(colored) => reopened = colored, | 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 | let reopened = Arc::new(reopened); | 593 | let reopened = Arc::new(reopened); |
| ... | @@ -651,7 +654,10 @@ impl State { | ... | @@ -651,7 +654,10 @@ impl State { |
| 651 | Structure::NewSection { .. } => match self.dated_page(None) { | 654 | Structure::NewSection { .. } => match self.dated_page(None) { |
| 652 | Ok(page) => Some(page), | 655 | Ok(page) => Some(page), |
| 653 | Err(error) => { | 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 | _ => None, | 663 | _ => None, |
crates/snowbound/src/navigation.rs+296-70| ... | @@ -15,12 +15,36 @@ pub struct Place { | ... | @@ -15,12 +15,36 @@ pub struct Place { |
| 15 | pub page: ExGuid, | 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 | /// Pages visited before and after the one shown, and those shown lately. | 42 | /// Pages visited before and after the one shown, and those shown lately. |
| 19 | #[derive(Default)] | 43 | #[derive(Default)] |
| 20 | pub struct Trail { | 44 | pub struct Trail { |
| 21 | back: Vec<Place>, | 45 | back: Vec<Visit>, |
| 22 | here: Option<Place>, | 46 | here: Option<Visit>, |
| 23 | forward: Vec<Place>, | 47 | forward: Vec<Visit>, |
| 24 | /// Each page shown lately once, latest first, kept between launches. | 48 | /// Each page shown lately once, latest first, kept between launches. |
| 25 | pub recent: Vec<Place>, | 49 | pub recent: Vec<Place>, |
| 26 | } | 50 | } |
| ... | @@ -33,25 +57,33 @@ const RECENT: usize = 8; | ... | @@ -33,25 +57,33 @@ const RECENT: usize = 8; |
| 33 | impl Trail { | 57 | impl Trail { |
| 34 | /// Follows the notebook at `from` to `to`, where it moved. | 58 | /// Follows the notebook at `from` to `to`, where it moved. |
| 35 | pub fn moved(&mut self, from: &str, to: &str) { | 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 | .chain(&mut self.here) | 61 | .chain(&mut self.here) |
| 38 | .chain(&mut self.forward) | 62 | .chain(&mut self.forward) |
| 39 | .chain(&mut self.recent); | 63 | .map(|visit| &mut visit.notebook); |
| 40 | for place in places.filter(|place| place.notebook == from) { | 64 | let places = self.recent.iter_mut().map(|place| &mut place.notebook); |
| 41 | place.notebook = to.to_owned(); | 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 | 70 | /// Notes `place` shown, as the recent pages list it. |
| 46 | /// ahead, as a browser does. | 71 | pub fn remember(&mut self, place: Place) { |
| 47 | pub fn visit(&mut self, place: Place) { | ||
| 48 | self.recent.retain(|recent| *recent != place); | 72 | self.recent.retain(|recent| *recent != place); |
| 49 | self.recent.insert(0, place.clone()); | 73 | self.recent.insert(0, place); |
| 50 | self.recent.truncate(RECENT); | 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 | return; | 84 | return; |
| 53 | } | 85 | } |
| 54 | if let Some(here) = self.here.replace(place) { | 86 | if let Some(here) = self.here.replace(visit) { |
| 55 | self.back.push(here); | 87 | self.back.push(here); |
| 56 | if self.back.len() > KEPT { | 88 | if self.back.len() > KEPT { |
| 57 | self.back.remove(0); | 89 | self.back.remove(0); |
| ... | @@ -60,24 +92,26 @@ impl Trail { | ... | @@ -60,24 +92,26 @@ impl Trail { |
| 60 | self.forward.clear(); | 92 | self.forward.clear(); |
| 61 | } | 93 | } |
| 62 | 94 | ||
| 63 | /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it | 95 | /// Steps back, or `forward`, to the nearest place `exists` accepts; the place to show. |
| 64 | /// refuses; the place to show. | 96 | /// Places it refuses, on either side, are dropped. |
| 65 | pub fn step(&mut self, forward: bool, exists: impl Fn(&Place) -> bool) -> Option<Place> { | 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 | let (from, to) = if forward { | 100 | let (from, to) = if forward { |
| 67 | (&mut self.forward, &mut self.back) | 101 | (&mut self.forward, &mut self.back) |
| 68 | } else { | 102 | } else { |
| 69 | (&mut self.back, &mut self.forward) | 103 | (&mut self.back, &mut self.forward) |
| 70 | }; | 104 | }; |
| 71 | let place = std::iter::from_fn(|| from.pop()).find(|place| exists(place))?; | 105 | let visit = from.pop()?; |
| 72 | if let Some(here) = self.here.replace(place.clone()) { | 106 | if let Some(here) = self.here.replace(visit.clone()) { |
| 73 | to.push(here); | 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. | 112 | /// Whether Back and Forward have somewhere `exists` accepts to go. |
| 79 | pub fn open(&self) -> [bool; 2] { | 113 | pub fn open(&self, exists: impl Fn(&Visit) -> bool) -> [bool; 2] { |
| 80 | [!self.back.is_empty(), !self.forward.is_empty()] | 114 | [&self.back, &self.forward].map(|side| side.iter().rev().any(&exists)) |
| 81 | } | 115 | } |
| 82 | 116 | ||
| 83 | /// Drops the recent pages whose section `listed` refuses, and those whose section `index` | 117 | /// Drops the recent pages whose section `listed` refuses, and those whose section `index` |
| ... | @@ -98,24 +132,90 @@ impl Trail { | ... | @@ -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 | impl State { | 173 | impl State { |
| 102 | /// Notes the page just shown for Back and Forward, and among the recent pages. | 174 | /// Notes the page just shown for Back and Forward, and among the recent pages. |
| 103 | pub(crate) fn visited(&mut self) { | 175 | pub(crate) fn visited(&mut self) { |
| 104 | let Some(session) = &self.session else { | 176 | let Some(session) = &self.session else { |
| 105 | return; | 177 | return; |
| 106 | }; | 178 | }; |
| 179 | let path = &session.tabs[session.tab].path; | ||
| 107 | let place = Place { | 180 | let place = Place { |
| 108 | notebook: session.library.location.clone(), | 181 | notebook: session.library.location.clone(), |
| 109 | section: session.tabs[session.tab].path.clone(), | 182 | section: path.clone(), |
| 110 | page: session.space, | 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 | let moved = self.trail.recent.first() != Some(&place); | 194 | let moved = self.trail.recent.first() != Some(&place); |
| 113 | self.trail.visit(place); | 195 | self.trail.remember(place); |
| 114 | if moved { | 196 | if moved { |
| 115 | self.save_settings(); | 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 | /// Whether `library`'s section at `path` is the one open. | 219 | /// Whether `library`'s section at `path` is the one open. |
| 120 | pub(crate) fn open(&self, library: &Library, path: &str) -> bool { | 220 | pub(crate) fn open(&self, library: &Library, path: &str) -> bool { |
| 121 | self.session.as_ref().is_some_and(|session| { | 221 | self.session.as_ref().is_some_and(|session| { |
| ... | @@ -134,28 +234,15 @@ impl State { | ... | @@ -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 | 237 | /// Shows the page visited before the one shown, or after it going `forward`, wherever it |
| 138 | /// deleted from the open section since is passed over. | 238 | /// went since. Pages since deleted, and those of notebooks since closed, are passed over. |
| 139 | pub(crate) fn travel(&mut self, forward: bool) { | 239 | pub(crate) fn travel(&mut self, forward: bool) { |
| 140 | let session = self.session.as_ref(); | 240 | let mut trail = std::mem::take(&mut self.trail); |
| 141 | let exists = |place: &Place| { | 241 | let visit = trail.step(forward, |visit| self.find(visit).is_some()); |
| 142 | session.is_none_or(|session| { | 242 | self.trail = trail; |
| 143 | session.library.location != place.notebook | 243 | if let Some((library, path, space)) = visit.and_then(|visit| self.find(&visit)) { |
| 144 | || session.tabs[session.tab].path != place.section | 244 | self.go(library, path, space); |
| 145 | || session.pages.iter().any(|(page, ..)| *page == place.page) | 245 | } |
| 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); | ||
| 159 | } | 246 | } |
| 160 | } | 247 | } |
| 161 | 248 | ||
| ... | @@ -240,53 +327,95 @@ mod tests { | ... | @@ -240,53 +327,95 @@ mod tests { |
| 240 | assert_eq!(stroke(&mut swipe, 200.0, 0.0, false, 4000), None); | 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 | #[test] | 346 | #[test] |
| 244 | fn back_and_forward_retrace_visits_across_sections() { | 347 | fn back_and_forward_retrace_visits_across_sections() { |
| 245 | let mut trail = Trail::default(); | 348 | let mut trail = Trail::default(); |
| 246 | assert_eq!(trail.open(), [false, false]); | 349 | let all = |_: &Visit| true; |
| 247 | for (section, page) in [("A.one", 1), ("A.one", 2), ("B.one", 1)] { | 350 | assert_eq!(trail.open(all), [false, false]); |
| 248 | trail.visit(place(section, page)); | 351 | for (section, page) in [(1, 1), (1, 2), (2, 1)] { |
| 352 | trail.visit(visit(section, page)); | ||
| 249 | } | 353 | } |
| 250 | // Showing the same page again, as a reload does, is no visit. | 354 | // Showing the same page again, as a reload does, is no visit. |
| 251 | trail.visit(place("B.one", 1)); | 355 | trail.visit(visit(2, 1)); |
| 252 | let all = |_: &Place| true; | 356 | assert_eq!(trail.step(false, all), Some(visit(1, 2))); |
| 253 | assert_eq!(trail.step(false, all), Some(place("A.one", 2))); | ||
| 254 | // Arriving where Back went is not a new visit. | 357 | // Arriving where Back went is not a new visit. |
| 255 | trail.visit(place("A.one", 2)); | 358 | trail.visit(visit(1, 2)); |
| 256 | assert_eq!(trail.open(), [true, true]); | 359 | assert_eq!(trail.open(all), [true, true]); |
| 257 | assert_eq!(trail.step(false, all), Some(place("A.one", 1))); | 360 | assert_eq!(trail.step(false, all), Some(visit(1, 1))); |
| 258 | assert_eq!(trail.step(false, all), None); | 361 | assert_eq!(trail.step(false, all), None); |
| 259 | assert_eq!(trail.step(true, all), Some(place("A.one", 2))); | 362 | assert_eq!(trail.step(true, all), Some(visit(1, 2))); |
| 260 | assert_eq!(trail.step(true, all), Some(place("B.one", 1))); | 363 | assert_eq!(trail.step(true, all), Some(visit(2, 1))); |
| 261 | assert_eq!(trail.step(true, all), None); | 364 | assert_eq!(trail.step(true, all), None); |
| 262 | // A page opened after going back drops the pages ahead. | 365 | // A page opened after going back drops the pages ahead. |
| 263 | trail.step(false, all); | 366 | trail.step(false, all); |
| 264 | trail.visit(place("C.one", 5)); | 367 | trail.visit(visit(3, 5)); |
| 265 | assert_eq!(trail.open(), [true, false]); | 368 | assert_eq!(trail.open(all), [true, false]); |
| 266 | assert_eq!(trail.step(false, all), Some(place("A.one", 2))); | 369 | assert_eq!(trail.step(false, all), Some(visit(1, 2))); |
| 267 | } | 370 | } |
| 268 | 371 | ||
| 269 | #[test] | 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 | let mut trail = Trail::default(); | 374 | let mut trail = Trail::default(); |
| 272 | for page in 1..=3 { | 375 | for page in 1..=4 { |
| 273 | trail.visit(place("A.one", page)); | 376 | trail.visit(visit(1, page)); |
| 274 | } | 377 | } |
| 275 | let kept = |place: &Place| place.page.n != 2; | 378 | let all = |_: &Visit| true; |
| 276 | assert_eq!(trail.step(false, kept), Some(place("A.one", 1))); | 379 | trail.step(false, all); |
| 277 | assert_eq!(trail.step(true, kept), Some(place("A.one", 3))); | 380 | // Pages 1 and 2 behind, 4 ahead; 2 and 4 are gone since. |
| 278 | assert_eq!(trail.open(), [true, false]); | 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 | #[test] | 410 | #[test] |
| 282 | fn recent_pages_are_kept_once_latest_first_and_bounded() { | 411 | fn recent_pages_are_kept_once_latest_first_and_bounded() { |
| 283 | let mut trail = Trail::default(); | 412 | let mut trail = Trail::default(); |
| 284 | for page in [1, 2, 1] { | 413 | for page in [1, 2, 1] { |
| 285 | trail.visit(place("A.one", page)); | 414 | trail.remember(place("A.one", page)); |
| 286 | } | 415 | } |
| 287 | assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]); | 416 | assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]); |
| 288 | for page in 0..20 { | 417 | for page in 0..20 { |
| 289 | trail.visit(place("B.one", page)); | 418 | trail.remember(place("B.one", page)); |
| 290 | } | 419 | } |
| 291 | assert_eq!(trail.recent.len(), RECENT); | 420 | assert_eq!(trail.recent.len(), RECENT); |
| 292 | assert_eq!(trail.recent[0], place("B.one", 19)); | 421 | assert_eq!(trail.recent[0], place("B.one", 19)); |
| ... | @@ -322,7 +451,7 @@ mod tests { | ... | @@ -322,7 +451,7 @@ mod tests { |
| 322 | place("A.one", 2), | 451 | place("A.one", 2), |
| 323 | kept.clone(), | 452 | kept.clone(), |
| 324 | ] { | 453 | ] { |
| 325 | trail.visit(gone); | 454 | trail.remember(gone); |
| 326 | } | 455 | } |
| 327 | let listed = |place: &Place| place.section != "C.one"; | 456 | let listed = |place: &Place| place.section != "C.one"; |
| 328 | assert!(trail.prune(&index, listed)); | 457 | assert!(trail.prune(&index, listed)); |
| ... | @@ -334,7 +463,7 @@ mod tests { | ... | @@ -334,7 +463,7 @@ mod tests { |
| 334 | fn back_keeps_its_last_hundred_places() { | 463 | fn back_keeps_its_last_hundred_places() { |
| 335 | let mut trail = Trail::default(); | 464 | let mut trail = Trail::default(); |
| 336 | for page in 0..150 { | 465 | for page in 0..150 { |
| 337 | trail.visit(place("A.one", page)); | 466 | trail.visit(visit(1, page)); |
| 338 | } | 467 | } |
| 339 | let mut steps = 0; | 468 | let mut steps = 0; |
| 340 | while trail.step(false, |_| true).is_some() { | 469 | while trail.step(false, |_| true).is_some() { |
| ... | @@ -342,4 +471,101 @@ mod tests { | ... | @@ -342,4 +471,101 @@ mod tests { |
| 342 | } | 471 | } |
| 343 | assert_eq!(steps, KEPT); | 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,7 +273,7 @@ impl State { |
| 273 | if go { | 273 | if go { |
| 274 | self.printing.last = Some(setup); | 274 | self.printing.last = Some(setup); |
| 275 | if let Err(error) = self.print(setup, export) { | 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,7 +379,7 @@ impl State { |
| 379 | } else { | 379 | } else { |
| 380 | "Couldn't print" | 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 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| { | 383 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| { |
| 384 | match done { | 384 | match done { |
| 385 | Ok(Some(pdf)) => printer::print(&state.window, &pdf, &title), | 385 | Ok(Some(pdf)) => printer::print(&state.window, &pdf, &title), |
crates/snowbound/src/protection.rs+8-2| ... | @@ -540,7 +540,10 @@ impl State { | ... | @@ -540,7 +540,10 @@ impl State { |
| 540 | Some(Dialog::Unlock(Zeroizing::default(), true)) | 540 | Some(Dialog::Unlock(Zeroizing::default(), true)) |
| 541 | } | 541 | } |
| 542 | Err(error) => { | 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 | None | 547 | None |
| 545 | } | 548 | } |
| 546 | }, | 549 | }, |
| ... | @@ -622,7 +625,10 @@ impl State { | ... | @@ -622,7 +625,10 @@ impl State { |
| 622 | Ok(()) | 625 | Ok(()) |
| 623 | }); | 626 | }); |
| 624 | if let Err(error) = closed { | 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 | self.prefetch.forget(&library.key(&path)); | 633 | self.prefetch.forget(&library.key(&path)); |
| 628 | } | 634 | } |
crates/snowbound/src/rename.rs+4-1| ... | @@ -115,7 +115,10 @@ impl State { | ... | @@ -115,7 +115,10 @@ impl State { |
| 115 | } | 115 | } |
| 116 | Target::Page(space) => { | 116 | Target::Page(space) => { |
| 117 | if let Err(error) = self.retitle(space, name) { | 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,7 +144,7 @@ impl State { |
| 144 | let dialog = self.save_as.take().expect("The dialog is open"); | 144 | let dialog = self.save_as.take().expect("The dialog is open"); |
| 145 | self.ui.close_popup(id()); | 145 | self.ui.close_popup(id()); |
| 146 | if go && let Err(error) = self.save(dialog) { | 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,7 +213,7 @@ impl State { |
| 213 | notebook::fs::write(&path, bytes)?; | 213 | notebook::fs::write(&path, bytes)?; |
| 214 | Ok(()) | 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 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { | 217 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { |
| 218 | written.map_err(|error| { | 218 | written.map_err(|error| { |
| 219 | platform::alert("Couldn't save", &error); | 219 | platform::alert("Couldn't save", &error); |
crates/snowbound/src/tags.rs+4-1| ... | @@ -621,7 +621,10 @@ impl State { | ... | @@ -621,7 +621,10 @@ impl State { |
| 621 | } | 621 | } |
| 622 | }); | 622 | }); |
| 623 | if let Err(error) = written { | 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 | tag.art = Some(art); | 629 | tag.art = Some(art); |
| 627 | if tag.shape == 0 { | 630 | if tag.shape == 0 { |
crates/snowbound/src/unpack.rs+1-1| ... | @@ -233,7 +233,7 @@ impl State { | ... | @@ -233,7 +233,7 @@ impl State { |
| 233 | } | 233 | } |
| 234 | Ok(location) | 234 | Ok(location) |
| 235 | })() | 235 | })() |
| 236 | .map_err(|error| error.to_string()); | 236 | .map_err(|error| crate::plain(&*error, "package")); |
| 237 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { | 237 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { |
| 238 | match unpacked { | 238 | match unpacked { |
| 239 | Ok(location) => state.open_notebook(location, None), | 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,7 +612,7 @@ pub fn open_file(path: &Path) { |
| 612 | &bytes, | 612 | &bytes, |
| 613 | "application/octet-stream", | 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,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 | /// The search box's results in `tree`, one a line. | 375 | /// The search box's results in `tree`, one a line. |
| 278 | fn search_results(tree: &str) -> Vec<&str> { | 376 | fn search_results(tree: &str) -> Vec<&str> { |
| 279 | tree.lines() | 377 | tree.lines() |