authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:42:58-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 22:23:49-07:00
log9361b752ce57b06d0f55af85dc5f515f794ea8f2
tree3709c64653c0e308d679ba30d25e94e0fec4f7c8
parent4c59412d43f497f29b4c3d4b9695134f634cf17f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: Back and Forward find pages by identity, and alerts never show raw I/O errors

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.5

15 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}
441443
...@@ -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}
57685771
5772/// The kind of the file or network failure behind `error`, if one is.
5773pub 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.
5792pub 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 first5817/// The session for `section`, at catalog `path` in `library`, showing `space` or its first
5770/// page, and that page.5818/// page, and that page.
5771fn read_session(5819fn 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;
66386686
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 was6708 /// 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}
1717
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)]
22pub 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
31impl 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)]
20pub struct Trail {44pub 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;
33impl Trail {57impl 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 }
4469
45 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages70 /// 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 }
6294
63 /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it95 /// 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 }
77111
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 }
82116
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}
100134
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.
138fn 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
101impl State {173impl 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 }
118200
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 }
136236
137 /// Shows the page visited before the one shown, or after it going `forward`. A page237 /// 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.notebook243 if let Some((library, path, space)) = visit.and_then(|visit| self.find(&visit)) {
144 || session.tabs[session.tab].path != place.section244 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}
161248
...@@ -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 }
242329
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 }
268371
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 }
280409
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 None547 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 }
150150
...@@ -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}
618618
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}
276276
277/// Whether `tree`'s toolbar offers Back and Forward.
278fn 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
286const BACK: [&str; 4] = [
287 "modifiers command control",
288 "key Left",
289 "modifiers",
290 "settle",
291];
292const FORWARD: [&str; 4] = [
293 "modifiers command control",
294 "key Right",
295 "modifiers",
296 "settle",
297];
298const 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]
303fn 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]
337fn 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.
278fn search_results(tree: &str) -> Vec<&str> {376fn search_results(tree: &str) -> Vec<&str> {
279 tree.lines()377 tree.lines()