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 {...@@ -1039,23 +1039,32 @@ impl State {
1039 library.purge_recycle_bin();1039 library.purge_recycle_bin();
1040 }1040 }
1041 }1041 }
1042 // Where the notebook was left, or its first section.
1042 let shown = notebooks1043 let shown = notebooks
1043 .iter()1044 .iter()
1044 .filter(|library| Some(&library.location) == current.as_ref())1045 .filter(|library| Some(&library.location) == current.as_ref())
1045 .chain(&notebooks)1046 .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 });
1047 match shown {1056 match shown {
1048 Some((library, path)) if !library.locked(&path) => {1057 Some((library, path, left)) if !library.locked(&path) => {
1049 let section = library.open(&path, notify(proxy.clone()))?;1058 let section = library.open(&path, notify(proxy.clone()))?;
1050 let (opened, page) =1059 let (opened, page) =
1051 read_session(section, Arc::clone(library), path, None)?;1060 read_session(section, Arc::clone(library), path, left)?;
1052 let (scene, editor) = PageScene::from_page(page, &mut engine)?;1061 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
1053 session = Some(opened);1062 session = Some(opened);
1054 (editor, Some((scene, [0.0; 2])))1063 (editor, Some((scene, [0.0; 2])))
1055 }1064 }
1056 shown => {1065 shown => {
1057 // A first section still locked shows its locked page.1066 // 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 {
1059 library: Arc::clone(library),1068 library: Arc::clone(library),
1060 path,1069 path,
1061 });1070 });
...@@ -1228,7 +1237,9 @@ impl State {...@@ -1228,7 +1237,9 @@ impl State {
1228 opening: None,1237 opening: None,
1229 switching: None,1238 switching: None,
1230 places: HashMap::new(),1239 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(),
1232 trail: {1243 trail: {
1233 let mut trail = navigation::Trail::default();1244 let mut trail = navigation::Trail::default();
1234 trail.recent = stored.recent;1245 trail.recent = stored.recent;
crates/snowbound/src/settings.rs+1-1
...@@ -46,7 +46,7 @@ pub struct Settings {...@@ -46,7 +46,7 @@ pub struct Settings {
46 pub servers: Vec<String>,46 pub servers: Vec<String>,
47 /// Chords set in Options' Keyboard, by command, each list in place of its defaults.47 /// Chords set in Options' Keyboard, by command, each list in place of its defaults.
48 pub keys: std::collections::BTreeMap<String, Vec<String>>,48 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.
50 pub recent: Vec<crate::navigation::Place>,50 pub recent: Vec<crate::navigation::Place>,
51 pub passwords: Passwords,51 pub passwords: Passwords,
52}52}
crates/snowbound/src/sidebar.rs+15-5
...@@ -1100,8 +1100,8 @@ impl crate::State {...@@ -1100,8 +1100,8 @@ impl crate::State {
1100 self.read_notebook(location, section, open, read);1100 self.read_notebook(location, section, open, read);
1101 }1101 }
11021102
1103 /// Shows `section`, or the first section, of the notebook at `location`, `open` or read1103 /// Shows `section`, or where it was left, or the first section, of the notebook at
1104 /// with `read`, in place of the notebook listed there.1104 /// `location`, `open` or read with `read`, in place of the notebook listed there.
1105 pub(crate) fn read_notebook(1105 pub(crate) fn read_notebook(
1106 &mut self,1106 &mut self,
1107 location: String,1107 location: String,
...@@ -1109,6 +1109,15 @@ impl crate::State {...@@ -1109,6 +1109,15 @@ impl crate::State {
1109 open: Option<Arc<Library>>,1109 open: Option<Arc<Library>>,
1110 read: impl FnOnce(&str, &std::path::Path) -> Result<Library, String> + Send + 'static,1110 read: impl FnOnce(&str, &std::path::Path) -> Result<Library, String> + Send + 'static,
1111 ) {1111 ) {
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();
1112 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));1121 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
1113 self.load(move || {1122 self.load(move || {
1114 let library = match open {1123 let library = match open {
...@@ -1122,9 +1131,10 @@ impl crate::State {...@@ -1122,9 +1131,10 @@ impl crate::State {
1122 if let Err(error) = &library.notebook {1131 if let Err(error) = &library.notebook {
1123 return Err(error.clone().into());1132 return Err(error.clone().into());
1124 }1133 }
1125 let Some(path) = section1134 let Some((path, left)) = section
1126 .filter(|path| library.contains(path))1135 .filter(|path| library.contains(path))
1127 .or_else(|| library.first_section())1136 .map(|path| (path, left))
1137 .or_else(|| Some((library.first_section()?, None)))
1128 else {1138 else {
1129 // Shown once its sections arrive.1139 // Shown once its sections arrive.
1130 if library.downloading() {1140 if library.downloading() {
...@@ -1133,7 +1143,7 @@ impl crate::State {...@@ -1133,7 +1143,7 @@ impl crate::State {
1133 return Err("This folder holds no notebook sections.".into());1143 return Err("This folder holds no notebook sections.".into());
1134 };1144 };
1135 let section = library.open(&path, notify)?;1145 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)?;
1137 Ok(crate::Loaded::Section(Box::new(session), page))1147 Ok(crate::Loaded::Section(Box::new(session), page))
1138 });1148 });
1139 }1149 }
crates/snowbound/tests/replay.rs+49-6
...@@ -12,6 +12,11 @@ impl Scratch {...@@ -12,6 +12,11 @@ impl Scratch {
12 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));12 let path = std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));
13 let _ = std::fs::remove_dir_all(&path);13 let _ = std::fs::remove_dir_all(&path);
14 std::fs::create_dir_all(path.join("notebook")).unwrap();14 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();
15 Self(path)20 Self(path)
16 }21 }
17}22}
...@@ -22,7 +27,8 @@ impl Drop for Scratch {...@@ -22,7 +27,8 @@ impl Drop for Scratch {
22 }27 }
23}28}
2429
25/// Runs `steps` against a copy of `notebook`'s files, then returns the trees each30/// Runs `steps` against a copy of `notebook`'s files with the scratch's settings, then
31/// returns the trees each
26/// `accessibility` step named by the trailing word wrote.32/// `accessibility` step named by the trailing word wrote.
27fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<String> {33fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<String> {
28 let dir = &scratch.0;34 let dir = &scratch.0;
...@@ -46,11 +52,6 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str...@@ -46,11 +52,6 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
46 }52 }
47 script += "quit\n";53 script += "quit\n";
48 std::fs::write(dir.join("script"), script).unwrap();54 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();
54 let status = Command::new(env!("CARGO_BIN_EXE_snowbound"))55 let status = Command::new(env!("CARGO_BIN_EXE_snowbound"))
55 .env("SNOWBOUND_REPLAY", dir.join("script"))56 .env("SNOWBOUND_REPLAY", dir.join("script"))
56 .arg("--notebook")57 .arg("--notebook")
...@@ -389,3 +390,45 @@ fn the_find_bar_keeps_room_for_the_query() {...@@ -389,3 +390,45 @@ fn the_find_bar_keeps_room_for_the_query() {
389 "{found}"390 "{found}"
390 );391 );
391}392}
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}