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 {
285285 /// The section's key, which the host chooses.
286286 pub section: String,
287287 pub space: ExGuid,
288 /// The page's own identity, which it keeps moving to another section.
289 pub identity: Option<[u8; 16]>,
288290 pub title: String,
289291 /// When the page last changed, in any unit that orders.
290292 pub modified: u64,
......@@ -301,6 +303,7 @@ impl Entry {
301303 Self {
302304 section: section.to_owned(),
303305 space,
306 identity: page.identity,
304307 folded_title: fold(&page.title),
305308 folded_text: fold(&text),
306309 title: page.title.clone(),
crates/snowbound/src/commands.rs+3-2
......@@ -1234,8 +1234,9 @@ impl State {
12341234 Id::Copy => enabled(selected),
12351235 Id::Paste => enabled(text && !field),
12361236 Id::SelectAll => enabled(field || page),
1237 Id::Back => enabled(!modal && self.trail.open()[0]),
1238 Id::Forward => enabled(!modal && self.trail.open()[1]),
1237 Id::Back | Id::Forward => {
1238 enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)])
1239 }
12391240 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page),
12401241 Id::Sidebar => Status {
12411242 enabled: !welcome && !modal,
crates/snowbound/src/desktop_linux.rs+7-2
......@@ -435,7 +435,9 @@ pub fn install() {
435435 "To uninstall it, open Options from the notebook menu. Uninstall is under Updates.",
436436 );
437437 }
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 }
439441 }
440442}
441443
......@@ -525,7 +527,10 @@ fn remove() {
525527 }
526528 refresh_caches(&data);
527529 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 ),
529534 None => *INSTALLED.lock().unwrap() = Installed::No,
530535 }
531536}
crates/snowbound/src/library.rs+6-9
......@@ -409,7 +409,10 @@ impl Library {
409409 tag_art: Mutex::new(Arc::new(
410410 notebook.as_ref().map(read_tag_art).unwrap_or_default(),
411411 )),
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 }),
413416 notice,
414417 ..Self::new(location, name, cache)
415418 }
......@@ -544,13 +547,7 @@ impl Library {
544547 .reopen()
545548 .and_then(|notebook| Ok(notebook.rename_folder(name)?));
546549 if let Err(error) = renamed {
547 let kind = match error.downcast_ref::<notebook::Error>() {
548 Some(notebook::Error::Io(error) | notebook::Error::RemoteIo(error)) => {
549 Some(error.kind())
550 }
551 _ => error.downcast_ref::<io::Error>().map(io::Error::kind),
552 };
553 return Err(match kind {
550 return Err(match crate::io_kind(&*error) {
554551 Some(io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy) => {
555552 "Another computer is saving to this notebook. Try again in a moment.".into()
556553 }
......@@ -564,7 +561,7 @@ impl Library {
564561 Some(io::ErrorKind::InvalidInput) => "A folder name can’t contain \\ / : * ? \" \
565562 < > | or end with a dot or space."
566563 .into(),
567 _ => error.to_string(),
564 _ => crate::plain(&*error, "folder"),
568565 });
569566 }
570567 // The folder's own name shows now.
crates/snowbound/src/main.rs+71-2
......@@ -3489,7 +3489,11 @@ impl State {
34893489 since: Instant::now(),
34903490 })))
34913491 });
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));
34933497 redraw.wake();
34943498 });
34953499 }
......@@ -3508,7 +3512,6 @@ impl State {
35083512 if id == self.loading {
35093513 self.switching = None;
35103514 }
3511 eprintln!("{error}");
35123515 platform::alert("Couldn't open", &error);
35133516 }
35143517 }
......@@ -5766,6 +5769,51 @@ fn divider(ui: &mut Ui, theme: &Theme) {
57665769 );
57675770}
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
57695817/// The session for `section`, at catalog `path` in `library`, showing `space` or its first
57705818/// page, and that page.
57715819fn read_session(
......@@ -6636,6 +6684,27 @@ mod tests {
66366684 use super::*;
66376685 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
66396708 /// A notebook opened from the desktop is listed once and shown first, whether it was
66406709 /// listed already or not.
66416710 #[test]
crates/snowbound/src/manage.rs+9-3
......@@ -554,7 +554,10 @@ impl State {
554554 None => Ok(()),
555555 });
556556 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 );
558561 }
559562 }
560563 let showing = shown.is_some() || self.sectionless.as_ref().is_some_and(ours);
......@@ -584,7 +587,7 @@ impl State {
584587 });
585588 match colored {
586589 Ok(colored) => reopened = colored,
587 Err(error) => problem = Some(error.to_string()),
590 Err(error) => problem = Some(crate::plain(&*error, "notebook")),
588591 }
589592 }
590593 let reopened = Arc::new(reopened);
......@@ -651,7 +654,10 @@ impl State {
651654 Structure::NewSection { .. } => match self.dated_page(None) {
652655 Ok(page) => Some(page),
653656 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 );
655661 }
656662 },
657663 _ => None,
crates/snowbound/src/navigation.rs+296-70
......@@ -15,12 +15,36 @@ pub struct Place {
1515 pub page: ExGuid,
1616}
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
1842/// Pages visited before and after the one shown, and those shown lately.
1943#[derive(Default)]
2044pub struct Trail {
21 back: Vec<Place>,
22 here: Option<Place>,
23 forward: Vec<Place>,
45 back: Vec<Visit>,
46 here: Option<Visit>,
47 forward: Vec<Visit>,
2448 /// Each page shown lately once, latest first, kept between launches.
2549 pub recent: Vec<Place>,
2650}
......@@ -33,25 +57,33 @@ const RECENT: usize = 8;
3357impl Trail {
3458 /// Follows the notebook at `from` to `to`, where it moved.
3559 pub fn moved(&mut self, from: &str, to: &str) {
36 let places = (self.back.iter_mut())
60 let visits = (self.back.iter_mut())
3761 .chain(&mut self.here)
3862 .chain(&mut self.forward)
39 .chain(&mut self.recent);
40 for place in places.filter(|place| place.notebook == from) {
41 place.notebook = to.to_owned();
63 .map(|visit| &mut visit.notebook);
64 let places = self.recent.iter_mut().map(|place| &mut place.notebook);
65 for notebook in visits.chain(places).filter(|notebook| *notebook == from) {
66 *notebook = to.to_owned();
4267 }
4368 }
4469
45 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
46 /// ahead, as a browser does.
47 pub fn visit(&mut self, place: Place) {
70 /// Notes `place` shown, as the recent pages list it.
71 pub fn remember(&mut self, place: Place) {
4872 self.recent.retain(|recent| *recent != place);
49 self.recent.insert(0, place.clone());
73 self.recent.insert(0, place);
5074 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;
5284 return;
5385 }
54 if let Some(here) = self.here.replace(place) {
86 if let Some(here) = self.here.replace(visit) {
5587 self.back.push(here);
5688 if self.back.len() > KEPT {
5789 self.back.remove(0);
......@@ -60,24 +92,26 @@ impl Trail {
6092 self.forward.clear();
6193 }
6294
63 /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it
64 /// refuses; the place to show.
65 pub fn step(&mut self, forward: bool, exists: impl Fn(&Place) -> bool) -> Option<Place> {
95 /// Steps back, or `forward`, to the nearest place `exists` accepts; the place to show.
96 /// Places it refuses, on either side, are dropped.
97 pub fn step(&mut self, forward: bool, exists: impl Fn(&Visit) -> bool) -> Option<Visit> {
98 self.back.retain(&exists);
99 self.forward.retain(&exists);
66100 let (from, to) = if forward {
67101 (&mut self.forward, &mut self.back)
68102 } else {
69103 (&mut self.back, &mut self.forward)
70104 };
71 let place = std::iter::from_fn(|| from.pop()).find(|place| exists(place))?;
72 if let Some(here) = self.here.replace(place.clone()) {
105 let visit = from.pop()?;
106 if let Some(here) = self.here.replace(visit.clone()) {
73107 to.push(here);
74108 }
75 Some(place)
109 Some(visit)
76110 }
77111
78 /// Whether Back and Forward have somewhere to go.
79 pub fn open(&self) -> [bool; 2] {
80 [!self.back.is_empty(), !self.forward.is_empty()]
112 /// Whether Back and Forward have somewhere `exists` accepts to go.
113 pub fn open(&self, exists: impl Fn(&Visit) -> bool) -> [bool; 2] {
114 [&self.back, &self.forward].map(|side| side.iter().rev().any(&exists))
81115 }
82116
83117 /// Drops the recent pages whose section `listed` refuses, and those whose section `index`
......@@ -98,24 +132,90 @@ impl Trail {
98132 }
99133}
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
101173impl State {
102174 /// Notes the page just shown for Back and Forward, and among the recent pages.
103175 pub(crate) fn visited(&mut self) {
104176 let Some(session) = &self.session else {
105177 return;
106178 };
179 let path = &session.tabs[session.tab].path;
107180 let place = Place {
108181 notebook: session.library.location.clone(),
109 section: session.tabs[session.tab].path.clone(),
182 section: path.clone(),
110183 page: session.space,
111184 };
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 }
112194 let moved = self.trail.recent.first() != Some(&place);
113 self.trail.visit(place);
195 self.trail.remember(place);
114196 if moved {
115197 self.save_settings();
116198 }
117199 }
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
119219 /// Whether `library`'s section at `path` is the one open.
120220 pub(crate) fn open(&self, library: &Library, path: &str) -> bool {
121221 self.session.as_ref().is_some_and(|session| {
......@@ -134,28 +234,15 @@ impl State {
134234 }
135235 }
136236
137 /// Shows the page visited before the one shown, or after it going `forward`. A page
138 /// deleted from the open section since is passed over.
237 /// Shows the page visited before the one shown, or after it going `forward`, wherever it
238 /// went since. Pages since deleted, and those of notebooks since closed, are passed over.
139239 pub(crate) fn travel(&mut self, forward: bool) {
140 let session = self.session.as_ref();
141 let exists = |place: &Place| {
142 session.is_none_or(|session| {
143 session.library.location != place.notebook
144 || session.tabs[session.tab].path != place.section
145 || session.pages.iter().any(|(page, ..)| *page == place.page)
146 })
147 };
148 let Some(place) = self.trail.step(forward, exists) else {
149 return;
150 };
151 let Some(library) = self
152 .notebooks
153 .iter()
154 .find(|library| library.location == place.notebook)
155 else {
156 return;
157 };
158 self.go(Arc::clone(library), place.section, place.page);
240 let mut trail = std::mem::take(&mut self.trail);
241 let visit = trail.step(forward, |visit| self.find(visit).is_some());
242 self.trail = trail;
243 if let Some((library, path, space)) = visit.and_then(|visit| self.find(&visit)) {
244 self.go(library, path, space);
245 }
159246 }
160247}
161248
......@@ -240,53 +327,95 @@ mod tests {
240327 assert_eq!(stroke(&mut swipe, 200.0, 0.0, false, 4000), None);
241328 }
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
243346 #[test]
244347 fn back_and_forward_retrace_visits_across_sections() {
245348 let mut trail = Trail::default();
246 assert_eq!(trail.open(), [false, false]);
247 for (section, page) in [("A.one", 1), ("A.one", 2), ("B.one", 1)] {
248 trail.visit(place(section, page));
349 let all = |_: &Visit| true;
350 assert_eq!(trail.open(all), [false, false]);
351 for (section, page) in [(1, 1), (1, 2), (2, 1)] {
352 trail.visit(visit(section, page));
249353 }
250354 // Showing the same page again, as a reload does, is no visit.
251 trail.visit(place("B.one", 1));
252 let all = |_: &Place| true;
253 assert_eq!(trail.step(false, all), Some(place("A.one", 2)));
355 trail.visit(visit(2, 1));
356 assert_eq!(trail.step(false, all), Some(visit(1, 2)));
254357 // Arriving where Back went is not a new visit.
255 trail.visit(place("A.one", 2));
256 assert_eq!(trail.open(), [true, true]);
257 assert_eq!(trail.step(false, all), Some(place("A.one", 1)));
358 trail.visit(visit(1, 2));
359 assert_eq!(trail.open(all), [true, true]);
360 assert_eq!(trail.step(false, all), Some(visit(1, 1)));
258361 assert_eq!(trail.step(false, all), None);
259 assert_eq!(trail.step(true, all), Some(place("A.one", 2)));
260 assert_eq!(trail.step(true, all), Some(place("B.one", 1)));
362 assert_eq!(trail.step(true, all), Some(visit(1, 2)));
363 assert_eq!(trail.step(true, all), Some(visit(2, 1)));
261364 assert_eq!(trail.step(true, all), None);
262365 // A page opened after going back drops the pages ahead.
263366 trail.step(false, all);
264 trail.visit(place("C.one", 5));
265 assert_eq!(trail.open(), [true, false]);
266 assert_eq!(trail.step(false, all), Some(place("A.one", 2)));
367 trail.visit(visit(3, 5));
368 assert_eq!(trail.open(all), [true, false]);
369 assert_eq!(trail.step(false, all), Some(visit(1, 2)));
267370 }
268371
269372 #[test]
270 fn back_passes_over_pages_since_deleted() {
373 fn back_and_forward_pass_over_pages_gone_and_go_dark_without_any() {
271374 let mut trail = Trail::default();
272 for page in 1..=3 {
273 trail.visit(place("A.one", page));
375 for page in 1..=4 {
376 trail.visit(visit(1, page));
274377 }
275 let kept = |place: &Place| place.page.n != 2;
276 assert_eq!(trail.step(false, kept), Some(place("A.one", 1)));
277 assert_eq!(trail.step(true, kept), Some(place("A.one", 3)));
278 assert_eq!(trail.open(), [true, false]);
378 let all = |_: &Visit| true;
379 trail.step(false, all);
380 // Pages 1 and 2 behind, 4 ahead; 2 and 4 are gone since.
381 let kept = |visit: &Visit| ![2, 4].contains(&visit.space.n);
382 assert_eq!(trail.open(kept), [true, false]);
383 assert_eq!(trail.step(true, kept), None);
384 assert_eq!(trail.step(false, kept), Some(visit(1, 1)));
385 // What was passed over is gone for good.
386 assert_eq!(trail.step(true, all), Some(visit(1, 3)));
387 assert_eq!(trail.open(all), [true, false]);
388 }
389
390 #[test]
391 fn arriving_at_a_page_moved_since_is_no_new_visit() {
392 let mut trail = Trail::default();
393 for page in 1..=2 {
394 trail.visit(visit(1, page));
395 }
396 let all = |_: &Visit| true;
397 trail.step(false, all);
398 // Page 1 now shows from section 2, under a new space, as Back found it.
399 let moved = Visit {
400 section: [2; 16],
401 space: visit(2, 9).space,
402 ..visit(1, 1)
403 };
404 trail.visit(moved.clone());
405 assert_eq!(trail.open(all), [false, true]);
406 assert_eq!(trail.step(true, all), Some(visit(1, 2)));
407 assert_eq!(trail.step(false, all), Some(moved));
279408 }
280409
281410 #[test]
282411 fn recent_pages_are_kept_once_latest_first_and_bounded() {
283412 let mut trail = Trail::default();
284413 for page in [1, 2, 1] {
285 trail.visit(place("A.one", page));
414 trail.remember(place("A.one", page));
286415 }
287416 assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]);
288417 for page in 0..20 {
289 trail.visit(place("B.one", page));
418 trail.remember(place("B.one", page));
290419 }
291420 assert_eq!(trail.recent.len(), RECENT);
292421 assert_eq!(trail.recent[0], place("B.one", 19));
......@@ -322,7 +451,7 @@ mod tests {
322451 place("A.one", 2),
323452 kept.clone(),
324453 ] {
325 trail.visit(gone);
454 trail.remember(gone);
326455 }
327456 let listed = |place: &Place| place.section != "C.one";
328457 assert!(trail.prune(&index, listed));
......@@ -334,7 +463,7 @@ mod tests {
334463 fn back_keeps_its_last_hundred_places() {
335464 let mut trail = Trail::default();
336465 for page in 0..150 {
337 trail.visit(place("A.one", page));
466 trail.visit(visit(1, page));
338467 }
339468 let mut steps = 0;
340469 while trail.step(false, |_| true).is_some() {
......@@ -342,4 +471,101 @@ mod tests {
342471 }
343472 assert_eq!(steps, KEPT);
344473 }
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 }
345571}
crates/snowbound/src/print.rs+2-2
......@@ -273,7 +273,7 @@ impl State {
273273 if go {
274274 self.printing.last = Some(setup);
275275 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"));
277277 }
278278 }
279279 }
......@@ -379,7 +379,7 @@ impl State {
379379 } else {
380380 "Couldn't print"
381381 };
382 let done = done.map_err(|error| error.to_string());
382 let done = done.map_err(|error| crate::plain(&*error, "file"));
383383 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| {
384384 match done {
385385 Ok(Some(pdf)) => printer::print(&state.window, &pdf, &title),
crates/snowbound/src/protection.rs+8-2
......@@ -540,7 +540,10 @@ impl State {
540540 Some(Dialog::Unlock(Zeroizing::default(), true))
541541 }
542542 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 );
544547 None
545548 }
546549 },
......@@ -622,7 +625,10 @@ impl State {
622625 Ok(())
623626 });
624627 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 );
626632 }
627633 self.prefetch.forget(&library.key(&path));
628634 }
crates/snowbound/src/rename.rs+4-1
......@@ -115,7 +115,10 @@ impl State {
115115 }
116116 Target::Page(space) => {
117117 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 );
119122 }
120123 }
121124 }
crates/snowbound/src/save_as.rs+2-2
......@@ -144,7 +144,7 @@ impl State {
144144 let dialog = self.save_as.take().expect("The dialog is open");
145145 self.ui.close_popup(id());
146146 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"));
148148 }
149149 }
150150
......@@ -213,7 +213,7 @@ impl State {
213213 notebook::fs::write(&path, bytes)?;
214214 Ok(())
215215 })();
216 let written = written.map_err(|error| error.to_string());
216 let written = written.map_err(|error| crate::plain(&*error, "file"));
217217 let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| {
218218 written.map_err(|error| {
219219 platform::alert("Couldn't save", &error);
crates/snowbound/src/tags.rs+4-1
......@@ -621,7 +621,10 @@ impl State {
621621 }
622622 });
623623 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 );
625628 }
626629 tag.art = Some(art);
627630 if tag.shape == 0 {
crates/snowbound/src/unpack.rs+1-1
......@@ -233,7 +233,7 @@ impl State {
233233 }
234234 Ok(location)
235235 })()
236 .map_err(|error| error.to_string());
236 .map_err(|error| crate::plain(&*error, "package"));
237237 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {
238238 match unpacked {
239239 Ok(location) => state.open_notebook(location, None),
crates/snowbound/src/web.rs+1-1
......@@ -612,7 +612,7 @@ pub fn open_file(path: &Path) {
612612 &bytes,
613613 "application/octet-stream",
614614 ),
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")),
616616 }
617617}
618618
crates/snowbound/tests/replay.rs+98
......@@ -274,6 +274,104 @@ fn undo_walks_back_through_new_pages_and_their_titles() {
274274 }
275275}
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
277375/// The search box's results in `tree`, one a line.
278376fn search_results(tree: &str) -> Vec<&str> {
279377 tree.lines()