authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 11:14:59-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 12:53:12-07:00
log282b96db3198a95fdb61aa5d1977a7b2c9e05828
treee185b1a8f6ab5754b33628ca4682ffaee1635137
parent6bc7028096a351aa0348bcbf6d76425c5a611103
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: A launch returns to the page each notebook was left on

The settings kept the pages shown lately, but a launch opened the shown notebook at its first section and page, and so did every section and notebook opened after. The launch now shows the latest of those pages in the shown notebook, and sections and notebooks opened later return to theirs. Assisted-by: claude-opus-5.5

4 files changed, 81 insertions(+), 17 deletions(-)

crates/snowbound/src/main.rs+16-5
......@@ -1039,23 +1039,32 @@ impl State {
10391039 library.purge_recycle_bin();
10401040 }
10411041 }
1042 // Where the notebook was left, or its first section.
10421043 let shown = notebooks
10431044 .iter()
10441045 .filter(|library| Some(&library.location) == current.as_ref())
10451046 .chain(&notebooks)
1046 .find_map(|library| Some((library, library.first_section()?)));
1047 .find_map(|library| {
1048 let left = stored.recent.iter().find(|place| {
1049 place.notebook == library.location && library.contains(&place.section)
1050 });
1051 match left {
1052 Some(place) => Some((library, place.section.clone(), Some(place.page))),
1053 None => Some((library, library.first_section()?, None)),
1054 }
1055 });
10471056 match shown {
1048 Some((library, path)) if !library.locked(&path) => {
1057 Some((library, path, left)) if !library.locked(&path) => {
10491058 let section = library.open(&path, notify(proxy.clone()))?;
10501059 let (opened, page) =
1051 read_session(section, Arc::clone(library), path, None)?;
1060 read_session(section, Arc::clone(library), path, left)?;
10521061 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
10531062 session = Some(opened);
10541063 (editor, Some((scene, [0.0; 2])))
10551064 }
10561065 shown => {
10571066 // A first section still locked shows its locked page.
1058 locked = shown.map(|(library, path)| protection::Locked {
1067 locked = shown.map(|(library, path, _)| protection::Locked {
10591068 library: Arc::clone(library),
10601069 path,
10611070 });
......@@ -1228,7 +1237,9 @@ impl State {
12281237 opening: None,
12291238 switching: None,
12301239 places: HashMap::new(),
1231 last_pages: HashMap::new(),
1240 last_pages: (stored.recent.iter().rev())
1241 .map(|place| (library::key(&place.notebook, &place.section), place.page))
1242 .collect(),
12321243 trail: {
12331244 let mut trail = navigation::Trail::default();
12341245 trail.recent = stored.recent;
crates/snowbound/src/settings.rs+1-1
......@@ -46,7 +46,7 @@ pub struct Settings {
4646 pub servers: Vec<String>,
4747 /// Chords set in Options' Keyboard, by command, each list in place of its defaults.
4848 pub keys: std::collections::BTreeMap<String, Vec<String>>,
49 /// Pages shown lately, latest first, which the palette lists first.
49 /// Pages shown lately, latest first, which the palette lists first and a launch returns to.
5050 pub recent: Vec<crate::navigation::Place>,
5151 pub passwords: Passwords,
5252}
crates/snowbound/src/sidebar.rs+15-5
......@@ -1100,8 +1100,8 @@ impl crate::State {
11001100 self.read_notebook(location, section, open, read);
11011101 }
11021102
1103 /// Shows `section`, or the first section, of the notebook at `location`, `open` or read
1104 /// with `read`, in place of the notebook listed there.
1103 /// Shows `section`, or where it was left, or the first section, of the notebook at
1104 /// `location`, `open` or read with `read`, in place of the notebook listed there.
11051105 pub(crate) fn read_notebook(
11061106 &mut self,
11071107 location: String,
......@@ -1109,6 +1109,15 @@ impl crate::State {
11091109 open: Option<Arc<Library>>,
11101110 read: impl FnOnce(&str, &std::path::Path) -> Result<Library, String> + Send + 'static,
11111111 ) {
1112 let section = section.or_else(|| {
1113 (self.trail.recent.iter())
1114 .find(|place| place.notebook == location)
1115 .map(|place| place.section.clone())
1116 });
1117 let left = section
1118 .as_ref()
1119 .and_then(|path| self.last_pages.get(&crate::library::key(&location, path)))
1120 .copied();
11121121 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
11131122 self.load(move || {
11141123 let library = match open {
......@@ -1122,9 +1131,10 @@ impl crate::State {
11221131 if let Err(error) = &library.notebook {
11231132 return Err(error.clone().into());
11241133 }
1125 let Some(path) = section
1134 let Some((path, left)) = section
11261135 .filter(|path| library.contains(path))
1127 .or_else(|| library.first_section())
1136 .map(|path| (path, left))
1137 .or_else(|| Some((library.first_section()?, None)))
11281138 else {
11291139 // Shown once its sections arrive.
11301140 if library.downloading() {
......@@ -1133,7 +1143,7 @@ impl crate::State {
11331143 return Err("This folder holds no notebook sections.".into());
11341144 };
11351145 let section = library.open(&path, notify)?;
1136 let (session, page) = crate::read_session(section, library, path, None)?;
1146 let (session, page) = crate::read_session(section, library, path, left)?;
11371147 Ok(crate::Loaded::Section(Box::new(session), page))
11381148 });
11391149 }
crates/snowbound/tests/replay.rs+49-6
......@@ -12,6 +12,11 @@ impl Scratch {
1212 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));
1313 let _ = std::fs::remove_dir_all(&path);
1414 std::fs::create_dir_all(path.join("notebook")).unwrap();
15 std::fs::write(
16 path.join("settings.json"),
17 r#"{"user_name": "Snowbound Test"}"#,
18 )
19 .unwrap();
1520 Self(path)
1621 }
1722}
......@@ -22,7 +27,8 @@ impl Drop for Scratch {
2227 }
2328}
2429
25/// Runs `steps` against a copy of `notebook`'s files, then returns the trees each
30/// Runs `steps` against a copy of `notebook`'s files with the scratch's settings, then
31/// returns the trees each
2632/// `accessibility` step named by the trailing word wrote.
2733fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<String> {
2834 let dir = &scratch.0;
......@@ -46,11 +52,6 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
4652 }
4753 script += "quit\n";
4854 std::fs::write(dir.join("script"), script).unwrap();
49 std::fs::write(
50 dir.join("settings.json"),
51 r#"{"user_name": "Snowbound Test"}"#,
52 )
53 .unwrap();
5455 let status = Command::new(env!("CARGO_BIN_EXE_snowbound"))
5556 .env("SNOWBOUND_REPLAY", dir.join("script"))
5657 .arg("--notebook")
......@@ -389,3 +390,45 @@ fn the_find_bar_keeps_room_for_the_query() {
389390 "{found}"
390391 );
391392}
393
394/// A launch shows the page each notebook was left on, as the settings recall them: the
395/// notebook shown at once, and another as its section is opened.
396#[test]
397fn a_launch_returns_to_the_page_each_notebook_was_left_on() {
398 let scratch = Scratch::new("left-on");
399 let source =
400 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
401 let other = scratch.0.join("other");
402 std::fs::create_dir_all(&other).unwrap();
403 for entry in std::fs::read_dir(&source).unwrap() {
404 let entry = entry.unwrap();
405 std::fs::copy(entry.path(), other.join(entry.file_name())).unwrap();
406 }
407 let place = |notebook: &Path, page: &str| serde_json::json!({"notebook": notebook, "section": "Cross.one", "page": page});
408 let settings = serde_json::json!({
409 "user_name": "Snowbound Test",
410 "sidebar": true,
411 "notebooks": [other],
412 "recent": [
413 // Type over a row, then Delete a whole table.
414 place(&scratch.0.join("notebook"), "{F8285B5C-5410-4A62-B7DC-43E408FE82AE},1"),
415 place(&other, "{9F114CE9-F7D5-4234-B7C3-FF0D79615BB2},1"),
416 ],
417 });
418 std::fs::write(scratch.0.join("settings.json"), settings.to_string()).unwrap();
419 // The other notebook's section in the sidebar.
420 let mut steps = vec!["accessibility launched", "move 63 104", "press", "release"];
421 steps.extend(["wait 4000", "accessibility other"]);
422 let shown = |tree: &str| page_tabs(tree).into_iter().find(|tab| tab.starts_with('*'));
423 let [launched, other] = replay(&scratch, Some(&source), &steps).try_into().unwrap();
424 assert_eq!(
425 shown(&launched).as_deref(),
426 Some("*Type over a row"),
427 "{launched}"
428 );
429 assert_eq!(
430 shown(&other).as_deref(),
431 Some("*Delete a whole table"),
432 "{other}"
433 );
434}