authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:39:43-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:52:40-07:00
log0289b50606536e89c716af39171ffc698788b1f2
treea97df45f83bccdd2ee225a0c76fb8ace89f7783e
parent384e5ef14c8d95bb74b186fe57af0cc578fa9276
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Notebook Properties can rename the notebook's folder too

OneNote 2010's Notebook Properties changes only the display name. A "Rename the folder too" box, on by default, shows under Display name once the name changes; checked, OK renames the notebook's folder. In the Windows 7 lab OneNote 2010, finding a notebook folder renamed outside it, drops the notebook from its list (even while running) and opens the renamed folder again with every file as it was: the TOC keeps its name (Open Notebook.onetoc2) and no byte changes, and the colour stays with it. So only the folder is renamed. Notebook::rename_folder renames it through the notebook's storage. On a share it first takes and lets go of OneNote's writer opening and locks on every section and TOC, refusing while another writer holds one; the server itself refuses a folder whose files are open. The notebook's replicas (copies' included) and its listing then follow, so edits queued offline publish into the renamed folder. The app stops the notebook's background (Background::stop now waits for its thread), closes the open section, renames on a thread and opens the notebook again in its place in the sidebar at the section it showed; display names, read state, recents, the sidebar's folds and last pages follow the new location, and settings save it. A refusal reopens the notebook as it was and says why; nothing changes. The dialog warns, for a notebook on a share, that other computers must reopen it from the new folder. In the app's iCloud Drive folder, a listing taken mid-rename neither closes the renamed notebook nor opens the old name, and a listing that finds the renamed folder first is folded into the same entry. Assisted-by: claude-opus-5.5

16 files changed, 663 insertions(+), 22 deletions(-)

arc/sync.md+3-2
...@@ -80,8 +80,9 @@ by its own path instead, the original being the one the folder's TOC lists....@@ -80,8 +80,9 @@ by its own path instead, the original being the one the folder's TOC lists.
8080
81When a notebook moves, its queue follows by one of three roads:81When a notebook moves, its queue follows by one of three roads:
8282
83- **the app moved it** (iOS Rename): `location::moved` moves the folder's83- **the app moved it** (iOS Rename, or Notebook Properties' "Rename the folder
84 replicas to the new location;84 too"): `location::moved` moves the folder's replicas to the new location, the
85 latter through `Notebook::rename_folder` once no other writer holds a file;
85- **something else moved it** (Finder): on open, a section with no replica takes86- **something else moved it** (Finder): on open, a section with no replica takes
86 one from the folder of a local location that no longer exists, if the file87 one from the folder of a local location that no longer exists, if the file
87 stands as that replica's base or has moved on from it (a higher header88 stands as that replica's base or has moved on from it (a higher header
crates/notebook/README.md+4
...@@ -330,6 +330,10 @@ unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle...@@ -330,6 +330,10 @@ unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle
330Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's330Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's
331file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still331file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still
332lists for a file gone from its folder gives way to the file an edit gives its name.332lists for a file gone from its folder gives way to the file an edit gives its name.
333`rename_folder` renames the notebook's own folder, which OneNote 2010 opens again from its
334new name with no file inside changed (it drops the old one from its list): on a share it
335first takes and lets go of OneNote's writer locks on every section and TOC, refusing with
336`WouldBlock` while another writer holds one, then the replicas and listing follow.
333Edits keep entries in the order their ordering numbers give; a rename or colour keeps337Edits keep entries in the order their ordering numbers give; a rename or colour keeps
334the numbers. Every created, renamed or moved file is placed with338the numbers. Every created, renamed or moved file is placed with
335`onestore::place_file`, which sets the header's ancestor to the parent TOC's339`onestore::place_file`, which sets the header's ancestor to the parent TOC's
crates/notebook/src/background.rs+18-3
...@@ -42,7 +42,7 @@ const SETTLE: Duration = Duration::from_secs(1);...@@ -42,7 +42,7 @@ const SETTLE: Duration = Duration::from_secs(1);
42/// its offline copy, where the notebook keeps them. A section a session holds is left to that42/// its offline copy, where the notebook keeps them. A section a session holds is left to that
43/// session's worker, which the watch wakes. Dropping requests cancellation without waiting for43/// session's worker, which the watch wakes. Dropping requests cancellation without waiting for
44/// the step in flight.44/// the step in flight.
45pub struct Background(Arc<Shared>);45pub struct Background(Arc<Shared>, Mutex<Option<crate::task::JoinHandle<()>>>);
4646
47/// A section for `Background::watch`, as its notebook knows it.47/// A section for `Background::watch`, as its notebook knows it.
48pub struct Known {48pub struct Known {
...@@ -138,7 +138,7 @@ impl Background {...@@ -138,7 +138,7 @@ impl Background {
138 });138 });
139 let weak = Arc::downgrade(&shared);139 let weak = Arc::downgrade(&shared);
140 let owner = Arc::clone(&shared);140 let owner = Arc::clone(&shared);
141 crate::task::spawn("onestore-background", move || async move {141 let thread = crate::task::spawn("onestore-background", move || async move {
142 let signal = &owner.signal;142 let signal = &owner.signal;
143 let mut bound: Option<(B, L)> = None;143 let mut bound: Option<(B, L)> = None;
144 let mut news = false;144 let mut news = false;
...@@ -295,7 +295,7 @@ impl Background {...@@ -295,7 +295,7 @@ impl Background {
295 }295 }
296 }296 }
297 })?;297 })?;
298 Ok(Self(shared))298 Ok(Self(shared, Mutex::new(Some(thread))))
299 }299 }
300300
301 /// Keeps the sections of a notebook on a share in sync while they are not open, with an301 /// Keeps the sections of a notebook on a share in sync while they are not open, with an
...@@ -448,6 +448,21 @@ impl Background {...@@ -448,6 +448,21 @@ impl Background {
448 self.0.signal.wake();448 self.0.signal.wake();
449 }449 }
450450
451 /// Stops for good, waiting for the step in flight, so that no replica stays open and no
452 /// watch holds the notebook's folder.
453 pub fn stop(&self) {
454 self.0.signal.stopped.store(true, Ordering::Release);
455 self.0.signal.wake();
456 let thread = self.1.lock().ok().and_then(|mut thread| thread.take());
457 // In the browser no step is in flight while another task runs.
458 #[cfg(not(target_arch = "wasm32"))]
459 if let Some(thread) = thread {
460 let _ = thread.join();
461 }
462 #[cfg(target_arch = "wasm32")]
463 drop(thread);
464 }
465
451 /// Working offline, nothing is checked until `wake`, or until working online again, which466 /// Working offline, nothing is checked until `wake`, or until working online again, which
452 /// lists every folder again.467 /// lists every folder again.
453 pub fn set_offline(&self, offline: bool) {468 pub fn set_offline(&self, offline: bool) {
crates/notebook/src/session.rs+97-7
...@@ -92,6 +92,9 @@ pub trait Storage: Send + Sync {...@@ -92,6 +92,9 @@ pub trait Storage: Send + Sync {
92 fn hide(&self, path: &str) -> Result<()>;92 fn hide(&self, path: &str) -> Result<()>;
93 /// Renames or moves a file or directory; an existing target is an error.93 /// Renames or moves a file or directory; an existing target is an error.
94 fn rename(&self, from: &str, to: &str) -> Result<()>;94 fn rename(&self, from: &str, to: &str) -> Result<()>;
95 /// Renames the notebook's own folder to `name` beside it once no other writer holds any
96 /// of `files`; the location it then has.
97 fn rename_root(&self, name: &str, files: &[String]) -> Result<String>;
95 /// Renames a file over another, replacing it.98 /// Renames a file over another, replacing it.
96 fn replace(&self, from: &str, to: &str) -> Result<()>;99 fn replace(&self, from: &str, to: &str) -> Result<()>;
97 /// Deletes a file or an empty directory.100 /// Deletes a file or an empty directory.
...@@ -211,6 +214,18 @@ impl Storage for Directory {...@@ -211,6 +214,18 @@ impl Storage for Directory {
211 Ok(fs::rename(self.path(from), self.path(to))?)214 Ok(fs::rename(self.path(from), self.path(to))?)
212 }215 }
213216
217 /// A local file system shows no other writer's hold; one that refuses to rename a folder
218 /// whose files are open says so as the rename fails.
219 fn rename_root(&self, name: &str, _: &[String]) -> Result<String> {
220 let to = self.0.with_file_name(name);
221 // A change of case alone finds the folder itself on a case-insensitive volume.
222 if fs::metadata(&to).is_ok() && fs::canonicalize(&to)? != self.0 {
223 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
224 }
225 fs::rename(&self.0, &to)?;
226 Ok(to.to_string_lossy().into_owned())
227 }
228
214 fn replace(&self, from: &str, to: &str) -> Result<()> {229 fn replace(&self, from: &str, to: &str) -> Result<()> {
215 Ok(fs::rename(self.path(from), self.path(to))?)230 Ok(fs::rename(self.path(from), self.path(to))?)
216 }231 }
...@@ -306,6 +321,29 @@ impl Storage for Share {...@@ -306,6 +321,29 @@ impl Storage for Share {
306 Ok(self.client.rename(&self.path(from), &self.path(to))?)321 Ok(self.client.rename(&self.path(from), &self.path(to))?)
307 }322 }
308323
324 fn rename_root(&self, name: &str, files: &[String]) -> Result<String> {
325 if self.root.is_empty() {
326 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
327 }
328 for file in files {
329 self.client.unheld(&self.path(file))?;
330 }
331 let (parent, _) = split(&self.root);
332 let taken = self
333 .client
334 .read_dir(parent, LIMITS.entries)?
335 .iter()
336 .any(|entry| {
337 entry.name.eq_ignore_ascii_case(name) && entry.name != split(&self.root).1
338 });
339 if taken {
340 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
341 }
342 let to = catalog_path(parent, name);
343 self.client.rename(&self.root, &to)?;
344 Ok(self.client.location(&to))
345 }
346
309 fn replace(&self, from: &str, to: &str) -> Result<()> {347 fn replace(&self, from: &str, to: &str) -> Result<()> {
310 Ok(self.client.replace(&self.path(from), &self.path(to))?)348 Ok(self.client.replace(&self.path(from), &self.path(to))?)
311 }349 }
...@@ -408,13 +446,8 @@ impl Notebook {...@@ -408,13 +446,8 @@ impl Notebook {
408 cache: impl AsRef<Path>,446 cache: impl AsRef<Path>,
409 ) -> Result<Self> {447 ) -> Result<Self> {
410 let cache = cache.as_ref().to_path_buf();448 let cache = cache.as_ref().to_path_buf();
411 let listings = cache.join("listings");449 let listing = listing(&cache, &storage.location());
412 fs::create_dir_all(&listings)?;450 fs::create_dir_all(listing.parent().unwrap_or(&cache))?;
413 let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16]
414 .iter()
415 .map(|byte| format!("{byte:02x}"))
416 .collect();
417 let listing = listings.join(format!("{name}.json"));
418 let mut read = fs::read(&listing)451 let mut read = fs::read(&listing)
419 .ok()452 .ok()
420 .and_then(|bytes| serde_json::from_slice(&bytes).ok())453 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
...@@ -738,6 +771,45 @@ impl Notebook {...@@ -738,6 +771,45 @@ impl Notebook {
738 Ok(renamed)771 Ok(renamed)
739 }772 }
740773
774 /// Renames the notebook's folder to `name`, as OneNote 2010 finds a notebook folder renamed
775 /// outside it: no file inside changes, and it opens the folder again by its new name. Refused,
776 /// `WouldBlock`, while another writer holds one of its sections or tables of contents, and
777 /// `AlreadyExists` where `name` is taken. The replicas and the catalog's listing follow, so
778 /// edits waiting publish to the renamed folder; every replica must be closed. Returns the
779 /// notebook's new location, as `crate::location` names it.
780 pub fn rename_folder(mut self, name: &str) -> Result<String> {
781 if !folder_name(name) {
782 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
783 }
784 let from = self.storage.location();
785 let files: Vec<String> = (self.catalog.folders())
786 .flat_map(|folder| {
787 let toc = folder.toc.as_ref().map(|toc| &toc.filename);
788 (toc.map(|toc| catalog_path(&folder.path, toc)).into_iter())
789 .chain(folder.sections.iter().map(|section| section.path.clone()))
790 })
791 .collect();
792 let to = self.storage.rename_root(name, &files)?;
793 let mut moves = vec![(from.clone(), to.clone())];
794 moves.extend(
795 (self.catalog.sections())
796 .filter(|section| section.copy)
797 .map(|section| {
798 (
799 replica_location(&from, section),
800 replica_location(&to, section),
801 )
802 }),
803 );
804 for (from, to) in moves {
805 crate::location::moved(&self.cache, &from, &to)?;
806 }
807 let _ = fs::remove_file(&self.listing);
808 self.listing = listing(&self.cache, &to);
809 self.keep_listing();
810 Ok(to)
811 }
812
741 /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it.813 /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it.
742 pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> {814 pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> {
743 let path = self.section_path(path)?.path.clone();815 let path = self.section_path(path)?.path.clone();
...@@ -1678,6 +1750,24 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {...@@ -1678,6 +1750,24 @@ pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {
1678 }))1750 }))
1679}1751}
16801752
1753/// Where the cache keeps what reading the catalog of the notebook at `location` took.
1754fn listing(cache: &Path, location: &str) -> PathBuf {
1755 let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16]
1756 .iter()
1757 .map(|byte| format!("{byte:02x}"))
1758 .collect();
1759 cache.join("listings").join(format!("{name}.json"))
1760}
1761
1762/// Whether `name` can name a folder on every system a notebook's readers use, Windows's
1763/// included.
1764fn folder_name(name: &str) -> bool {
1765 component(name)
1766 && !name.contains(['<', '>', ':', '"', '|', '?', '*'])
1767 && !name.chars().any(char::is_control)
1768 && !name.ends_with(['.', ' '])
1769}
1770
1681fn split(path: &str) -> (&str, &str) {1771fn split(path: &str) -> (&str, &str) {
1682 match path.rsplit_once('/') {1772 match path.rsplit_once('/') {
1683 Some((folder, name)) => (folder, name),1773 Some((folder, name)) => (folder, name),
crates/notebook/src/session/tests.rs+3
...@@ -49,6 +49,9 @@ impl Storage for Racing {...@@ -49,6 +49,9 @@ impl Storage for Racing {
49 fn rename(&self, from: &str, to: &str) -> Result<()> {49 fn rename(&self, from: &str, to: &str) -> Result<()> {
50 self.inner.rename(from, to)50 self.inner.rename(from, to)
51 }51 }
52 fn rename_root(&self, name: &str, files: &[String]) -> Result<String> {
53 self.inner.rename_root(name, files)
54 }
52 fn replace(&self, from: &str, to: &str) -> Result<()> {55 fn replace(&self, from: &str, to: &str) -> Result<()> {
53 self.inner.replace(from, to)56 self.inner.replace(from, to)
54 }57 }
crates/notebook/src/sidecar/tests.rs+4
...@@ -139,6 +139,10 @@ impl Storage for Folder {...@@ -139,6 +139,10 @@ impl Storage for Folder {
139 Ok(())139 Ok(())
140 }140 }
141141
142 fn rename_root(&self, _: &str, _: &[String]) -> Result<String> {
143 unreachable!()
144 }
145
142 fn replace(&self, from: &str, to: &str) -> Result<()> {146 fn replace(&self, from: &str, to: &str) -> Result<()> {
143 let mut files = self.files.lock().unwrap();147 let mut files = self.files.lock().unwrap();
144 let bytes = files.remove(from).ok_or_else(missing)?;148 let bytes = files.remove(from).ok_or_else(missing)?;
crates/notebook/src/smb/mod.rs+6
...@@ -329,6 +329,12 @@ impl Client {...@@ -329,6 +329,12 @@ impl Client {
329 file.close()329 file.close()
330 }330 }
331331
332 /// Takes and lets go of OneNote 2010's writer opening and locks on the file at `path`, as a
333 /// commit does; `WouldBlock` while another writer holds them.
334 pub(crate) fn unheld(&self, path: &str) -> io::Result<()> {
335 self.open(path, true)?.coordinate(path, true, &[])?.close()
336 }
337
332 /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010338 /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010
333 /// skips a hidden folder.339 /// skips a hidden folder.
334 pub(crate) fn hide(&self, path: &str) -> io::Result<()> {340 pub(crate) fn hide(&self, path: &str) -> io::Result<()> {
crates/notebook/src/smb/tests.rs+66
...@@ -835,6 +835,72 @@ fn live_structure() {...@@ -835,6 +835,72 @@ fn live_structure() {
835 client.delete(&root).unwrap();835 client.delete(&root).unwrap();
836}836}
837837
838/// Renames a notebook folder of its own on the share, signing in as `ONESTORE_SMB_LAB_USER`
839/// where set: refused while another client holds OneNote's writer locks on a section, then
840/// done with every file as it was.
841#[test]
842#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
843fn live_folder_rename() {
844 use crate::session::Notebook;
845 let (user, password) = (
846 std::env::var("ONESTORE_SMB_LAB_USER").unwrap_or_default(),
847 std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(),
848 );
849 let connect = || {
850 let credentials = Credentials {
851 username: &user,
852 password: &password,
853 domain: "",
854 };
855 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
856 Client::connect(&address, "agent", credentials, Duration::from_secs(10)).unwrap()
857 };
858 let (client, other) = (std::sync::Arc::new(connect()), connect());
859 let parent = format!("rename-{}", std::process::id());
860 let (root, renamed) = (format!("{parent}/Before"), format!("{parent}/After"));
861 client.create_directory(&parent).unwrap();
862 client.create_directory(&root).unwrap();
863 let section = onestore::create_section("First.one", "First page", "Author").unwrap();
864 client
865 .create(&format!("{root}/First.one"), &section)
866 .unwrap();
867 let id = onestore::Store::parse(&section).unwrap().header.file_id;
868 let toc =
869 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", id)]).unwrap();
870 client
871 .create(&format!("{root}/Open Notebook.onetoc2"), &toc)
872 .unwrap();
873 let cache = tempfile::tempdir().unwrap();
874 let open = || Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap();
875 let held = format!("{root}/First.one");
876 let writer = other
877 .open(&held, true)
878 .unwrap()
879 .coordinate(&held, true, &[])
880 .unwrap();
881 let refused = open().rename_folder("After").unwrap_err();
882 assert!(
883 matches!(&refused, crate::Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock),
884 "{refused}"
885 );
886 assert!(client.read_dir(&root, 10).is_ok());
887 writer.close().unwrap();
888 let to = open().rename_folder("After").unwrap();
889 assert_eq!(to, client.location(&renamed));
890 assert!(client.read_dir(&root, 10).is_err());
891 assert_eq!(
892 client
893 .read_storage(&format!("{renamed}/First.one"), 1 << 20)
894 .unwrap(),
895 section
896 );
897 for path in ["First.one", "Open Notebook.onetoc2"] {
898 client.delete(&format!("{renamed}/{path}")).unwrap();
899 }
900 client.delete(&renamed).unwrap();
901 client.delete(&parent).unwrap();
902}
903
838/// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a904/// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a
839/// guest, to a server with a share `agent`; only lists and reads.905/// guest, to a server with a share `agent`; only lists and reads.
840#[test]906#[test]
crates/notebook/tests/location.rs+53
...@@ -167,6 +167,59 @@ fn a_notebook_the_app_moves_keeps_its_queued_edits() {...@@ -167,6 +167,59 @@ fn a_notebook_the_app_moves_keeps_its_queued_edits() {
167 assert_eq!(published(&root, &cache), 0);167 assert_eq!(published(&root, &cache), 0);
168}168}
169169
170#[test]
171fn renaming_a_notebook_folder_keeps_its_queued_edits_and_files() {
172 let directory = tempfile::tempdir().unwrap();
173 let cache = directory.path().join("cache");
174 let root = notebook(directory.path(), "Notebook");
175 let space = queued(&root, &cache, "Queued ");
176 let before = std::fs::read(root.join("Second.one")).unwrap();
177 let renamed = directory.path().join("Renamed");
178 let to = Notebook::open(&root, &cache)
179 .unwrap()
180 .rename_folder("Renamed")
181 .unwrap();
182 assert_eq!(to, notebook::location::local(&renamed).unwrap());
183 assert!(std::fs::metadata(&root).is_err());
184 // OneNote finds nothing inside changed.
185 assert_eq!(std::fs::read(renamed.join("Second.one")).unwrap(), before);
186 // A new notebook where the old one was takes nothing of the queue.
187 notebook(directory.path(), "Notebook");
188 assert_eq!(published(&renamed, &cache), 1);
189 assert!(stored(&renamed.join("First.one"), space).starts_with("Queued "));
190 assert_eq!(published(&root, &cache), 0);
191}
192
193#[test]
194fn a_notebook_folder_rename_refuses_a_taken_or_unportable_name_and_changes_nothing() {
195 let directory = tempfile::tempdir().unwrap();
196 let cache = directory.path().join("cache");
197 let root = notebook(directory.path(), "Notebook");
198 notebook(directory.path(), "Taken");
199 for (name, kind) in [
200 ("Taken", std::io::ErrorKind::AlreadyExists),
201 ("Notes: 2026", std::io::ErrorKind::InvalidInput),
202 ("a/b", std::io::ErrorKind::InvalidInput),
203 ("Trailing.", std::io::ErrorKind::InvalidInput),
204 ] {
205 let error = Notebook::open(&root, &cache)
206 .unwrap()
207 .rename_folder(name)
208 .unwrap_err();
209 assert!(
210 matches!(&error, notebook::Error::Io(error) if error.kind() == kind),
211 "{name}: {error}"
212 );
213 assert!(std::fs::metadata(root.join("First.one")).is_ok());
214 }
215 // A change of case alone renames the folder itself.
216 let to = Notebook::open(&root, &cache)
217 .unwrap()
218 .rename_folder("NOTEBOOK")
219 .unwrap();
220 assert!(to.ends_with("NOTEBOOK"));
221}
222
170#[test]223#[test]
171fn a_notebook_moved_outside_the_app_takes_its_replicas_along() {224fn a_notebook_moved_outside_the_app_takes_its_replicas_along() {
172 let directory = tempfile::tempdir().unwrap();225 let directory = tempfile::tempdir().unwrap();
crates/snowbound/src/library.rs+214-6
...@@ -452,12 +452,76 @@ impl Library {...@@ -452,12 +452,76 @@ impl Library {
452 /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote452 /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote
453 /// 2010's Notebook Properties does; the notebook read again with `with` shows it.453 /// 2010's Notebook Properties does; the notebook read again with `with` shows it.
454 pub fn set_display_name(&self, name: &str) -> io::Result<()> {454 pub fn set_display_name(&self, name: &str) -> io::Result<()> {
455 let file = display_names(&self.cache);455 edit_display_names(&self.cache, |names| {
456 let mut names = read_display_names(&file);456 names.insert(self.location.clone(), name.to_owned());
457 names.insert(self.location.clone(), name.to_owned());457 })
458 let partial = file.with_extension("partial");458 }
459 notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;459
460 notebook::fs::rename(partial, file)460 /// Where the notebook would be with its folder named `name`; none for a section opened on
461 /// its own, or a notebook at the top of its share.
462 pub fn renamed_location(&self, name: &str) -> Option<String> {
463 self.catalog()?;
464 if let Some(mut mount) = server_address(&self.location) {
465 let (parent, _) = mount.root.rsplit_once('/').unwrap_or(("", &mount.root));
466 mount.root = match (parent, mount.root.is_empty()) {
467 (_, true) => return None,
468 ("", false) => name.to_owned(),
469 (parent, false) => format!("{parent}/{name}"),
470 };
471 return Some(mount.url());
472 }
473 let parent = Path::new(&self.location).parent()?;
474 Some(parent.join(name).to_string_lossy().into_owned())
475 }
476
477 /// Renames the notebook's folder to `name` once nothing of this notebook holds its files:
478 /// its background stopped and its kept sections closed, as the open section must be too.
479 /// The replicas, and edits waiting in them, follow, and the folder's own name takes the
480 /// place of a display name. Refused, with nothing changed, while another writer holds a file
481 /// of it. Returns the notebook's new location, where it opens again, or why not, as its
482 /// reader is told.
483 pub fn rename_folder(&self, name: &str) -> Result<String, String> {
484 let to = self
485 .renamed_location(name)
486 .ok_or("This notebook’s folder can’t be renamed.")?;
487 if let Some(background) = &self.background {
488 background.stop();
489 }
490 self.close_kept();
491 let renamed = self
492 .reopen()
493 .and_then(|notebook| Ok(notebook.rename_folder(name)?));
494 if let Err(error) = renamed {
495 let kind = match error.downcast_ref::<notebook::Error>() {
496 Some(notebook::Error::Io(error) | notebook::Error::RemoteIo(error)) => {
497 Some(error.kind())
498 }
499 _ => error.downcast_ref::<io::Error>().map(io::Error::kind),
500 };
501 return Err(match kind {
502 Some(io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy) => {
503 "Another computer is saving to this notebook. Try again in a moment.".into()
504 }
505 Some(io::ErrorKind::PermissionDenied) => "Its files are open on another \
506 computer, or you can’t rename folders there. Close the notebook in OneNote \
507 on other computers, then try again."
508 .into(),
509 Some(io::ErrorKind::AlreadyExists) => {
510 format!("A folder named “{name}” is already there. Choose another name.")
511 }
512 Some(io::ErrorKind::InvalidInput) => "A folder name can’t contain \\ / : * ? \" \
513 < > | or end with a dot or space."
514 .into(),
515 _ => error.to_string(),
516 });
517 }
518 // The folder's own name shows now.
519 if let Err(error) = edit_display_names(&self.cache, |names| {
520 names.remove(&self.location);
521 }) {
522 eprintln!("{}: {error}", self.location);
523 }
524 Ok(to)
461 }525 }
462526
463 /// The art the notebook's tags draw with.527 /// The art the notebook's tags draw with.
...@@ -795,6 +859,11 @@ impl Library {...@@ -795,6 +859,11 @@ impl Library {
795 .then(|| Path::new(&self.location))859 .then(|| Path::new(&self.location))
796 }860 }
797861
862 /// Whether the notebook is on an SMB share, however it is reached.
863 pub fn on_smb(&self) -> bool {
864 self.server.is_some() || server_address(&self.location).is_some() || self.notice.is_some()
865 }
866
798 /// Whether iCloud Drive keeps the notebook, or the section opened on its own.867 /// Whether iCloud Drive keeps the notebook, or the section opened on its own.
799 pub fn in_icloud(&self) -> bool {868 pub fn in_icloud(&self) -> bool {
800 self.folder().is_some_and(crate::icloud::ubiquitous)869 self.folder().is_some_and(crate::icloud::ubiquitous)
...@@ -1038,6 +1107,18 @@ fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String>...@@ -1038,6 +1107,18 @@ fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String>
1038 .unwrap_or_default()1107 .unwrap_or_default()
1039}1108}
10401109
1110fn edit_display_names(
1111 cache: &Path,
1112 edit: impl FnOnce(&mut std::collections::BTreeMap<String, String>),
1113) -> io::Result<()> {
1114 let file = display_names(cache);
1115 let mut names = read_display_names(&file);
1116 edit(&mut names);
1117 let partial = file.with_extension("partial");
1118 notebook::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;
1119 notebook::fs::rename(partial, file)
1120}
1121
1041fn display_name(cache: &Path, location: &str) -> Option<String> {1122fn display_name(cache: &Path, location: &str) -> Option<String> {
1042 read_display_names(&display_names(cache)).remove(location)1123 read_display_names(&display_names(cache)).remove(location)
1043}1124}
...@@ -1428,6 +1509,133 @@ mod tests {...@@ -1428,6 +1509,133 @@ mod tests {
1428 }1509 }
1429 }1510 }
14301511
1512 /// Renames a notebook folder on the lab share (`ONESTORE_SMB_LAB`, as above) with an edit
1513 /// queued offline, which publishes into the renamed folder; `notebook`'s
1514 /// `live_folder_rename` covers the refusal while another writer holds a section.
1515 #[test]
1516 #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
1517 fn a_notebook_folder_on_a_share_renames_with_its_queue() {
1518 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
1519 let parent = format!("snowbound-rename-{}", std::process::id());
1520 let user = std::env::var("ONESTORE_SMB_LAB_USER").ok();
1521 let mount = |root: &str| Mount {
1522 server: address.clone(),
1523 share: "agent".into(),
1524 user: user.clone(),
1525 domain: String::new(),
1526 root: format!("{parent}/{root}"),
1527 };
1528 let login = Login {
1529 user: user.clone().unwrap_or_default(),
1530 password: std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(),
1531 domain: String::new(),
1532 };
1533 let connect = || {
1534 Server {
1535 mount: mount("Before"),
1536 login: login.clone(),
1537 }
1538 .connect()
1539 .unwrap()
1540 };
1541 let client = Arc::new(connect());
1542 remove_tree(&client, &parent);
1543 client.create_directory(&parent).unwrap();
1544 client.create_directory(&mount("Before").root).unwrap();
1545 let cache = std::env::temp_dir().join(&parent);
1546 let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap();
1547 Notebook::open_smb(Arc::clone(&client), &mount("Before").root, &cache)
1548 .unwrap()
1549 .create_section("", "Queued", &page)
1550 .unwrap();
1551 let open = |root: &str| {
1552 let location = mount(root).url();
1553 Library::on_share(&location, mount(root), login.clone(), &cache).unwrap()
1554 };
1555 let library = open("Before");
1556 let section = library.open("Queued.one", || {}).unwrap();
1557 section.set_offline(true);
1558 let (space, ..) = section.pages().unwrap()[0].clone();
1559 let title = (section.page(space).unwrap().objects.iter())
1560 .find_map(|object| match object {
1561 onestore::page::PageObject::Title(title) => {
1562 title.outlines[0].paragraphs[0].text().map(|text| text.id)
1563 }
1564 _ => None,
1565 })
1566 .unwrap();
1567 let typed = onestore::op::Edit {
1568 at: crate::filetime(),
1569 ops: vec![onestore::op::Op::Page {
1570 space,
1571 op: onestore::op::PageOp::Text {
1572 text: title,
1573 range: 0..0,
1574 with: "Renamed over SMB".into(),
1575 },
1576 }],
1577 };
1578 section.apply("Rust Author", typed).unwrap();
1579 section.close().unwrap();
1580 assert_eq!(
1581 library.rename_folder("After").unwrap(),
1582 mount("After").url()
1583 );
1584 assert!(client.read_dir(&mount("Before").root, 10).is_err());
1585 let renamed = open("After");
1586 let section = renamed.open("Queued.one", || {}).unwrap();
1587 let file = format!("{}/Queued.one", mount("After").root);
1588 let deadline = std::time::Instant::now() + Duration::from_secs(60);
1589 loop {
1590 let titles = client
1591 .read_storage(&file, LIMIT)
1592 .ok()
1593 .and_then(|bytes| {
1594 let arena = onestore::Arena::default();
1595 onestore::Section::open(&arena, bytes).ok()?.pages().ok()
1596 })
1597 .unwrap_or_default();
1598 if titles
1599 .iter()
1600 .any(|(_, title, _)| title == "Renamed over SMB")
1601 {
1602 break;
1603 }
1604 assert!(std::time::Instant::now() < deadline, "{titles:?}");
1605 section.wake();
1606 std::thread::sleep(Duration::from_millis(200));
1607 }
1608 section.close().unwrap();
1609 drop((library, renamed));
1610 let _ = notebook::fs::remove_dir_all(&cache);
1611 remove_tree(&client, &parent);
1612 }
1613
1614 #[test]
1615 fn a_notebook_folder_renames_unless_its_name_is_taken() {
1616 let directory =
1617 std::env::temp_dir().join(format!("snowbound-rename-{}", std::process::id()));
1618 let cache = directory.join("cache");
1619 let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
1620 notebook::fs::create_dir_all(&directory).unwrap();
1621 for name in ["Mine", "Taken"] {
1622 Notebook::create(directory.join(name), &cache, Notebook::NEW_COLOR, &page).unwrap();
1623 }
1624 let location = |name: &str| directory.join(name).to_string_lossy().into_owned();
1625 let library = Library::notebook(&location("Mine"), &cache);
1626 library.set_display_name("Shown").unwrap();
1627 let refused = library.rename_folder("Taken").unwrap_err();
1628 assert!(refused.starts_with("A folder named “Taken”"), "{refused}");
1629 let library = Library::notebook(&location("Mine"), &cache);
1630 assert_eq!(library.name, "Shown");
1631 assert_eq!(library.rename_folder("Ours").unwrap(), location("Ours"));
1632 assert!(notebook::fs::metadata(location("Mine")).is_err());
1633 let renamed = Library::notebook(&location("Ours"), &cache);
1634 assert_eq!(renamed.name, "Ours");
1635 assert_eq!(renamed.color(), Some(Notebook::NEW_COLOR));
1636 notebook::fs::remove_dir_all(&directory).unwrap();
1637 }
1638
1431 #[test]1639 #[test]
1432 fn share_relative_sections_show_under_their_mount() {1640 fn share_relative_sections_show_under_their_mount() {
1433 let library = |root: &str| Library {1641 let library = |root: &str| Library {
crates/snowbound/src/main.rs+7
...@@ -811,6 +811,8 @@ struct State {...@@ -811,6 +811,8 @@ struct State {
811 /// Notebooks in iCloud Drive a thread is reading, or following the download of, by811 /// Notebooks in iCloud Drive a thread is reading, or following the download of, by
812 /// location.812 /// location.
813 icloud_reading: HashSet<String>,813 icloud_reading: HashSet<String>,
814 /// The notebook locations a folder rename takes a notebook from and to, while it does.
815 folder_rename: Option<(String, String)>,
814 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.816 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
815 tags: Vec<canvas::editor::NoteTag>,817 tags: Vec<canvas::editor::NoteTag>,
816 /// The Customize Tags dialog's list while it is open.818 /// The Customize Tags dialog's list while it is open.
...@@ -1197,6 +1199,7 @@ impl State {...@@ -1197,6 +1199,7 @@ impl State {
1197 peers: None,1199 peers: None,
1198 server: None,1200 server: None,
1199 icloud_reading: HashSet::new(),1201 icloud_reading: HashSet::new(),
1202 folder_rename: None,
1200 tags: stored1203 tags: stored
1201 .tags1204 .tags
1202 .unwrap_or_else(canvas::editor::NoteTag::defaults),1205 .unwrap_or_else(canvas::editor::NoteTag::defaults),
...@@ -3160,6 +3163,10 @@ impl State {...@@ -3160,6 +3163,10 @@ impl State {
3160 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {3163 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
3161 match command {3164 match command {
3162 Command::OpenSection(library, path) => {3165 Command::OpenSection(library, path) => {
3166 // Its files are on their way to the renamed folder.
3167 if self.folder_renaming(&library.location) {
3168 return Ok(());
3169 }
3163 if library.locked(&path) {3170 if library.locked(&path) {
3164 return self.show_locked(library, path);3171 return self.show_locked(library, path);
3165 }3172 }
crates/snowbound/src/manage.rs+133-2
...@@ -298,7 +298,11 @@ impl State {...@@ -298,7 +298,11 @@ impl State {
298 .notebooks298 .notebooks
299 .iter()299 .iter()
300 .filter(|library| {300 .filter(|library| {
301 in_icloud_folder(&library.location) && !listed.contains(&library.location)301 in_icloud_folder(&library.location)
302 && !listed.contains(&library.location)
303 && !self.folder_renaming(&library.location)
304 // A listing taken before a rename names the folder by its old name.
305 && notebook::fs::metadata(&library.location).is_err()
302 })306 })
303 .cloned()307 .cloned()
304 .collect();308 .collect();
...@@ -307,16 +311,21 @@ impl State {...@@ -307,16 +311,21 @@ impl State {
307 }311 }
308 for location in listed {312 for location in listed {
309 if self.notebooks.iter().any(|open| open.location == location)313 if self.notebooks.iter().any(|open| open.location == location)
314 || self.folder_renaming(&location)
310 || !self.icloud_reading.insert(location.clone())315 || !self.icloud_reading.insert(location.clone())
311 {316 {
312 continue;317 continue;
313 }318 }
314 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());319 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
315 crate::spawn(move || {320 crate::spawn(move || {
321 let gone = notebook::fs::metadata(&location).is_err();
316 let library = Arc::new(Library::notebook(&location, &cache));322 let library = Arc::new(Library::notebook(&location, &cache));
317 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {323 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
318 state.icloud_reading.remove(&location);324 state.icloud_reading.remove(&location);
319 if state.notebooks.iter().any(|open| open.location == location) {325 if gone
326 || state.notebooks.iter().any(|open| open.location == location)
327 || state.folder_renaming(&location)
328 {
320 return Ok(());329 return Ok(());
321 }330 }
322 state.notebooks.push(Arc::clone(&library));331 state.notebooks.push(Arc::clone(&library));
...@@ -329,6 +338,13 @@ impl State {...@@ -329,6 +338,13 @@ impl State {
329 }338 }
330 }339 }
331340
341 /// Whether a folder rename is taking the notebook from or to `location`.
342 pub(crate) fn folder_renaming(&self, location: &str) -> bool {
343 self.folder_rename
344 .as_ref()
345 .is_some_and(|(from, to)| from == location || to == location)
346 }
347
332 /// Shows `library`, a notebook that just arrived or whose sections did, where no other348 /// Shows `library`, a notebook that just arrived or whose sections did, where no other
333 /// notebook is shown: at its first section, or without one while none is here yet.349 /// notebook is shown: at its first section, or without one while none is here yet.
334 pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) {350 pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) {
...@@ -502,6 +518,121 @@ impl State {...@@ -502,6 +518,121 @@ impl State {
502 self.save_settings();518 self.save_settings();
503 }519 }
504520
521 /// Renames `library`'s folder to `name` on a thread of its own, the notebook closed
522 /// meanwhile, then opens it from there in its place, at the section it showed, with `color`
523 /// given it. What this computer keeps by the notebook's location follows it. Refused, the
524 /// notebook opens again as it was.
525 pub(crate) fn rename_notebook(
526 &mut self,
527 library: Arc<Library>,
528 name: String,
529 color: Option<u32>,
530 ) {
531 let Some(to) = library.renamed_location(&name) else {
532 return;
533 };
534 let ours = |shown: &Arc<Library>| Arc::ptr_eq(shown, &library);
535 let shown = self
536 .session
537 .as_ref()
538 .filter(|session| ours(&session.library))
539 .map(|session| session.tabs[session.tab].path.clone());
540 if shown.is_some() {
541 let closed = self.persist().and_then(|()| match self.session.take() {
542 Some(session) => Ok(session.section.close()?),
543 None => Ok(()),
544 });
545 if let Err(error) = closed {
546 return platform::alert("Couldn't rename the folder", &error.to_string());
547 }
548 }
549 let showing = shown.is_some() || self.sectionless.as_ref().is_some_and(ours);
550 if showing {
551 self.sectionless = Some(Arc::clone(&library));
552 self.title();
553 }
554 self.folder_rename = Some((library.location.clone(), to.clone()));
555 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
556 crate::spawn(move || {
557 let renamed = library.rename_folder(&name);
558 // A failure after the folder moved, as its replicas followed, leaves it there.
559 let moved = library
560 .folder()
561 .is_some_and(|folder| notebook::fs::metadata(folder).is_err());
562 let location = match &renamed {
563 Ok(to) => to,
564 Err(_) if moved => &to,
565 Err(_) => &library.location,
566 };
567 let mut reopened = Library::notebook(location, &cache);
568 let mut problem = renamed.err();
569 if let (None, Some(color)) = (&problem, color) {
570 let colored = reopened.reopen().and_then(|mut notebook| {
571 notebook.set_color(color)?;
572 Ok(reopened.with(notebook))
573 });
574 match colored {
575 Ok(colored) => reopened = colored,
576 Err(error) => problem = Some(error.to_string()),
577 }
578 }
579 let reopened = Arc::new(reopened);
580 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
581 state.renamed_notebook(&library, reopened, showing.then_some(shown));
582 if let Some(problem) = problem {
583 platform::alert("Couldn't rename the folder", &problem);
584 }
585 Ok(())
586 })));
587 });
588 }
589
590 /// Lists `reopened` in the place of `old`, the notebook `rename_notebook` closed, moving
591 /// what this computer keeps by its location, and shows it again where `shown`: at the
592 /// section it showed, or its first.
593 fn renamed_notebook(
594 &mut self,
595 old: &Arc<Library>,
596 reopened: Arc<Library>,
597 shown: Option<Option<String>>,
598 ) {
599 self.folder_rename = None;
600 let (from, to) = (old.location.clone(), reopened.location.clone());
601 match (self.notebooks.iter_mut()).find(|open| Arc::ptr_eq(open, old)) {
602 Some(open) => *open = Arc::clone(&reopened),
603 None => self.notebooks.push(Arc::clone(&reopened)),
604 }
605 // iCloud Drive's listing may have found the renamed folder first.
606 let mut listed = false;
607 self.notebooks
608 .retain(|open| open.location != to || !std::mem::replace(&mut listed, true));
609 if from != to {
610 self.undo.close(&from);
611 self.trail.moved(&from, &to);
612 self.reads.moved(&from, &to);
613 let (from, to) = (crate::library::key(&from, ""), crate::library::key(&to, ""));
614 let rekey = |key: &String| match key.strip_prefix(&from) {
615 Some(rest) => format!("{to}{rest}"),
616 None => key.clone(),
617 };
618 self.folded = self.folded.iter().map(rekey).collect();
619 self.last_pages = (self.last_pages.iter())
620 .map(|(key, space)| (rekey(key), *space))
621 .collect();
622 }
623 if let Some(shown) = shown {
624 self.sectionless = Some(Arc::clone(&reopened));
625 self.title();
626 if let Some(path) = shown
627 .filter(|path| reopened.contains(path))
628 .or_else(|| reopened.first_section())
629 {
630 self.commands.push(Command::OpenSection(reopened, path));
631 }
632 }
633 self.save_settings();
634 }
635
505 /// Changes `library`'s sections and groups on a thread of its own, then shows the636 /// Changes `library`'s sections and groups on a thread of its own, then shows the
506 /// section the change leaves open: a new or moved one, or the one shown before.637 /// section the change leaves open: a new or moved one, or the one shown before.
507 pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) {638 pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) {
crates/snowbound/src/navigation.rs+11
...@@ -31,6 +31,17 @@ const KEPT: usize = 100;...@@ -31,6 +31,17 @@ const KEPT: usize = 100;
31const RECENT: usize = 8;31const RECENT: usize = 8;
3232
33impl Trail {33impl Trail {
34 /// Follows the notebook at `from` to `to`, where it moved.
35 pub fn moved(&mut self, from: &str, to: &str) {
36 let places = (self.back.iter_mut())
37 .chain(&mut self.here)
38 .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();
42 }
43 }
44
34 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages45 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
35 /// ahead, as a browser does.46 /// ahead, as a browser does.
36 pub fn visit(&mut self, place: Place) {47 pub fn visit(&mut self, place: Place) {
crates/snowbound/src/properties.rs+32-1
...@@ -15,6 +15,8 @@ pub struct Properties {...@@ -15,6 +15,8 @@ pub struct Properties {
15 name: String,15 name: String,
16 /// A colour picked, COLORREF.16 /// A colour picked, COLORREF.
17 color: Option<u32>,17 color: Option<u32>,
18 /// Renames the notebook's folder to a name changed.
19 rename_folder: bool,
18}20}
1921
20fn id() -> Id {22fn id() -> Id {
...@@ -37,6 +39,7 @@ impl State {...@@ -37,6 +39,7 @@ impl State {
37 name: library.name.clone(),39 name: library.name.clone(),
38 library,40 library,
39 color: None,41 color: None,
42 rename_folder: true,
40 });43 });
41 self.ui.open_popup(id());44 self.ui.open_popup(id());
42 self.ui.focus_all(name_field());45 self.ui.focus_all(name_field());
...@@ -126,7 +129,28 @@ impl State {...@@ -126,7 +129,28 @@ impl State {
126 if let Some(node) = ui.access(name_field()) {129 if let Some(node) = ui.access(name_field()) {
127 node.set_label("Display name");130 node.set_label("Display name");
128 }131 }
129 dim(ui, "hint", "Doesn’t change the notebook’s folder name");132 let renamable = dialog.name.trim() != dialog.library.name
133 && (dialog.library.renamed_location(dialog.name.trim())).is_some();
134 if renamable {
135 let rename = ui::check_box(ui, "rename", "Rename the folder too", dialog.rename_folder);
136 if rename.clicked {
137 dialog.rename_folder = !dialog.rename_folder;
138 }
139 if dialog.rename_folder && dialog.library.on_smb() {
140 ui.leaf(
141 "shared",
142 Spec {
143 flags: ui::Flags::CLIP,
144 size: [fill(), px(row * 0.8)],
145 text: Some("Other computers must reopen it from the new folder"),
146 icon: Some(art::WARNING),
147 ..Spec::default()
148 },
149 );
150 }
151 } else {
152 dim(ui, "hint", "Doesn’t change the notebook’s folder name");
153 }
130 label(ui, "Color:");154 label(ui, "Color:");
131 let shown = dialog.color.or(dialog.library.color());155 let shown = dialog.color.or(dialog.library.color());
132 let named = SECTION_COLORS156 let named = SECTION_COLORS
...@@ -161,6 +185,13 @@ impl State {...@@ -161,6 +185,13 @@ impl State {
161 ui.close();185 ui.close();
162 ui.close();186 ui.close();
163 let name = dialog.name.trim();187 let name = dialog.name.trim();
188 if ok && !name.is_empty() && renamable && dialog.rename_folder {
189 let (library, name, color) =
190 (Arc::clone(&dialog.library), name.to_owned(), dialog.color);
191 self.ui.close_popup(id());
192 self.properties = None;
193 return self.rename_notebook(library, name, color);
194 }
164 if ok && !name.is_empty() {195 if ok && !name.is_empty() {
165 if name != dialog.library.name || dialog.color.is_some() {196 if name != dialog.library.name || dialog.color.is_some() {
166 self.commands.push(Command::Structure(197 self.commands.push(Command::Structure(
crates/snowbound/src/sidebar.rs+4-1
...@@ -1231,6 +1231,7 @@ impl crate::State {...@@ -1231,6 +1231,7 @@ impl crate::State {
1231 /// it, with a button to add one; or while none is on this computer yet.1231 /// it, with a button to add one; or while none is on this computer yet.
1232 pub(crate) fn no_sections(&mut self, library: Arc<Library>) {1232 pub(crate) fn no_sections(&mut self, library: Arc<Library>) {
1233 let downloading = library.downloading();1233 let downloading = library.downloading();
1234 let renaming = self.folder_renaming(&library.location);
1234 let new = crate::Command::Structure(1235 let new = crate::Command::Structure(
1235 library,1236 library,
1236 crate::manage::Structure::NewSection {1237 crate::manage::Structure::NewSection {
...@@ -1238,7 +1239,9 @@ impl crate::State {...@@ -1238,7 +1239,9 @@ impl crate::State {
1238 },1239 },
1239 );1240 );
1240 let id = self.ui.id("no sections");1241 let id = self.ui.id("no sections");
1241 let (title, buttons) = if downloading {1242 let (title, buttons) = if renaming {
1243 ("Renaming the folder…", Vec::new())
1244 } else if downloading {
1242 ("Downloading from iCloud Drive…", Vec::new())1245 ("Downloading from iCloud Drive…", Vec::new())
1243 } else {1246 } else {
1244 (1247 (
crates/snowbound/src/unread.rs+8
...@@ -91,6 +91,14 @@ impl Reads {...@@ -91,6 +91,14 @@ impl Reads {
91 })91 })
92 }92 }
9393
94 /// Keeps what was read in the notebook at `from` for it at `to`, where it moved.
95 pub fn moved(&mut self, from: &str, to: &str) {
96 if let Some(kept) = self.notebooks.remove(from) {
97 self.notebooks.insert(to.to_owned(), kept);
98 self.save();
99 }
100 }
101
94 /// Whether Show Unread Changes is on for the notebook at `notebook`.102 /// Whether Show Unread Changes is on for the notebook at `notebook`.
95 pub fn shown(&self, notebook: &str) -> bool {103 pub fn shown(&self, notebook: &str) -> bool {
96 self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden)104 self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden)