| author | |
| committer | |
| log | 8c01efc1126e6d58946a2209125df65e46d10145 |
| tree | 279207eaa5bc7c921924c33360da520d1d7abd67 |
| parent | 57278aa264f7bbda73a30923db9b52a5541ae034 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
Show submitted section and group names until their own storage operation finishes.
Serialize notebook structure changes, resolve repeated renames by file identity,
and reconcile successful catalogs without superseding newer navigation. Retarget
open section workers in place after moves, preserving queued edits and the editor
while a cancelled remote attempt finishes off-thread.
Cover repeated and failed renames, stale labels across catalog refreshes, and a
queued edit whose old remote stalls or fails after its section moves.
Fixes #109
Refs #108
Assisted-by: gpt-6.1-sol9 files changed, 610 insertions(+), 51 deletions(-)
Cargo.lock+1| ... | @@ -3627,6 +3627,7 @@ dependencies = [ | ... | @@ -3627,6 +3627,7 @@ dependencies = [ |
| 3627 | "accesskit_consumer", | 3627 | "accesskit_consumer", |
| 3628 | "accesskit_winit", | 3628 | "accesskit_winit", |
| 3629 | "arboard", | 3629 | "arboard", |
| 3630 | "async-lock", | ||
| 3630 | "base64", | 3631 | "base64", |
| 3631 | "block2 0.5.1", | 3632 | "block2 0.5.1", |
| 3632 | "canvas", | 3633 | "canvas", |
crates/notebook/src/session.rs+51-15| ... | @@ -2139,10 +2139,30 @@ impl Section { | ... | @@ -2139,10 +2139,30 @@ impl Section { |
| 2139 | } | 2139 | } |
| 2140 | }); | 2140 | }); |
| 2141 | let observed: Arc<Mutex<Observed>> = Arc::new(Mutex::new(None)); | 2141 | let observed: Arc<Mutex<Observed>> = Arc::new(Mutex::new(None)); |
| 2142 | let worker = { | 2142 | let mut section = Self { |
| 2143 | let (sender, notify) = (sender.clone(), Arc::clone(&notify)); | 2143 | file, |
| 2144 | let observed = Arc::clone(&observed); | 2144 | replica, |
| 2145 | replica.start_sync(Duration::from_secs(2), connect, move |result| { | 2145 | worker: None, |
| 2146 | events: Mutex::new(events), | ||
| 2147 | sender, | ||
| 2148 | notify, | ||
| 2149 | observed, | ||
| 2150 | }; | ||
| 2151 | section.worker = Some(section.start_worker(connect, None)?); | ||
| 2152 | Ok(section) | ||
| 2153 | } | ||
| 2154 | |||
| 2155 | fn start_worker<R: Remote + 'static>( | ||
| 2156 | &self, | ||
| 2157 | connect: impl FnMut() -> io::Result<R> + Send + 'static, | ||
| 2158 | replacing: Option<&SyncWorker>, | ||
| 2159 | ) -> Result<SyncWorker> { | ||
| 2160 | let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify)); | ||
| 2161 | let observed = Arc::clone(&self.observed); | ||
| 2162 | Ok(self.replica.start_sync_replacing( | ||
| 2163 | Duration::from_secs(2), | ||
| 2164 | connect, | ||
| 2165 | move |result| { | ||
| 2146 | let error = result.as_ref().err().map(reached); | 2166 | let error = result.as_ref().err().map(reached); |
| 2147 | // Reaching the file as the last attempt did is no news to the host. | 2167 | // Reaching the file as the last attempt did is no news to the host. |
| 2148 | let mut changed = false; | 2168 | let mut changed = false; |
| ... | @@ -2182,17 +2202,33 @@ impl Section { | ... | @@ -2182,17 +2202,33 @@ impl Section { |
| 2182 | { | 2202 | { |
| 2183 | notify(); | 2203 | notify(); |
| 2184 | } | 2204 | } |
| 2185 | })? | 2205 | }, |
| 2186 | }; | 2206 | replacing, |
| 2187 | Ok(Self { | 2207 | )?) |
| 2188 | file, | 2208 | } |
| 2189 | replica, | 2209 | |
| 2190 | worker: Some(worker), | 2210 | /// Follows a section file moved within its notebook, keeping its replica and queued edits. |
| 2191 | events: Mutex::new(events), | 2211 | pub fn relocate(&mut self, file: impl AsRef<Path>) -> Result<()> { |
| 2192 | sender, | 2212 | self.relocate_with(fs::absolute(file)?, |file| Ok(FileRemote(file.to_owned()))) |
| 2193 | notify, | 2213 | } |
| 2194 | observed, | 2214 | |
| 2195 | }) | 2215 | /// `relocate`, with a host's publication target. Cancellation leaves any in-flight step |
| 2216 | /// to finish off-thread; synchronization ownership serializes the replacement worker. | ||
| 2217 | pub fn relocate_with<R: Remote + 'static>( | ||
| 2218 | &mut self, | ||
| 2219 | file: impl AsRef<Path>, | ||
| 2220 | mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static, | ||
| 2221 | ) -> Result<()> { | ||
| 2222 | let file = file.as_ref().to_owned(); | ||
| 2223 | let target = file.clone(); | ||
| 2224 | let previous = self.worker.take(); | ||
| 2225 | if let Some(worker) = &previous { | ||
| 2226 | worker.cancel(); | ||
| 2227 | } | ||
| 2228 | self.observed = Arc::new(Mutex::new(None)); | ||
| 2229 | self.worker = Some(self.start_worker(move || connect(&target), previous.as_ref())?); | ||
| 2230 | self.file = file; | ||
| 2231 | Ok(()) | ||
| 2196 | } | 2232 | } |
| 2197 | 2233 | ||
| 2198 | /// The absolute local path, or share-relative path for an SMB session. | 2234 | /// The absolute local path, or share-relative path for an SMB session. |
crates/notebook/src/worker.rs+37-5| ... | @@ -86,6 +86,11 @@ pub struct SyncWorker { | ... | @@ -86,6 +86,11 @@ pub struct SyncWorker { |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | impl SyncWorker { | 88 | impl SyncWorker { |
| 89 | pub(crate) fn cancel(&self) { | ||
| 90 | self.signal.stopped.store(true, Ordering::Release); | ||
| 91 | self.signal.wake(); | ||
| 92 | } | ||
| 93 | |||
| 89 | /// Requests a retry, for example after a network reachability change. Working | 94 | /// Requests a retry, for example after a network reachability change. Working |
| 90 | /// offline, it synchronizes once, as OneNote's Sync Now does. | 95 | /// offline, it synchronizes once, as OneNote's Sync Now does. |
| 91 | /// A pending contention backoff finishes before processing the notification. | 96 | /// A pending contention backoff finishes before processing the notification. |
| ... | @@ -120,8 +125,7 @@ impl SyncWorker { | ... | @@ -120,8 +125,7 @@ impl SyncWorker { |
| 120 | /// A stopped worker leaves pending edits and uncertain attempts in the cache. | 125 | /// A stopped worker leaves pending edits and uncertain attempts in the cache. |
| 121 | /// Call outside the worker's own callback, which cannot join its calling thread. | 126 | /// Call outside the worker's own callback, which cannot join its calling thread. |
| 122 | pub fn stop(mut self) -> Result<()> { | 127 | pub fn stop(mut self) -> Result<()> { |
| 123 | self.signal.stopped.store(true, Ordering::Release); | 128 | self.cancel(); |
| 124 | self.signal.wake(); | ||
| 125 | let thread = self.thread.take().expect("Worker owns its thread"); | 129 | let thread = self.thread.take().expect("Worker owns its thread"); |
| 126 | #[cfg(target_arch = "wasm32")] | 130 | #[cfg(target_arch = "wasm32")] |
| 127 | return thread.finished().unwrap_or(Ok(())); | 131 | return thread.finished().unwrap_or(Ok(())); |
| ... | @@ -150,8 +154,7 @@ impl SyncWorker { | ... | @@ -150,8 +154,7 @@ impl SyncWorker { |
| 150 | 154 | ||
| 151 | impl Drop for SyncWorker { | 155 | impl Drop for SyncWorker { |
| 152 | fn drop(&mut self) { | 156 | fn drop(&mut self) { |
| 153 | self.signal.stopped.store(true, Ordering::Release); | 157 | self.cancel(); |
| 154 | self.signal.wake(); | ||
| 155 | } | 158 | } |
| 156 | } | 159 | } |
| 157 | 160 | ||
| ... | @@ -166,10 +169,25 @@ impl Replica { | ... | @@ -166,10 +169,25 @@ impl Replica { |
| 166 | /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. | 169 | /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. |
| 167 | /// Remote calls and callbacks must be bounded for `stop` to have bounded latency. | 170 | /// Remote calls and callbacks must be bounded for `stop` to have bounded latency. |
| 168 | pub fn start_sync<R, F, O>( | 171 | pub fn start_sync<R, F, O>( |
| 172 | self: &Arc<Self>, | ||
| 173 | interval: Duration, | ||
| 174 | connect: F, | ||
| 175 | observe: O, | ||
| 176 | ) -> io::Result<SyncWorker> | ||
| 177 | where | ||
| 178 | R: Remote + 'static, | ||
| 179 | F: FnMut() -> io::Result<R> + Send + 'static, | ||
| 180 | O: FnMut(&Result<Synced>) + Send + 'static, | ||
| 181 | { | ||
| 182 | self.start_sync_replacing(interval, connect, observe, None) | ||
| 183 | } | ||
| 184 | |||
| 185 | pub(crate) fn start_sync_replacing<R, F, O>( | ||
| 169 | self: &Arc<Self>, | 186 | self: &Arc<Self>, |
| 170 | interval: Duration, | 187 | interval: Duration, |
| 171 | mut connect: F, | 188 | mut connect: F, |
| 172 | mut observe: O, | 189 | mut observe: O, |
| 190 | replacing: Option<&SyncWorker>, | ||
| 173 | ) -> io::Result<SyncWorker> | 191 | ) -> io::Result<SyncWorker> |
| 174 | where | 192 | where |
| 175 | R: Remote + 'static, | 193 | R: Remote + 'static, |
| ... | @@ -187,10 +205,24 @@ impl Replica { | ... | @@ -187,10 +205,24 @@ impl Replica { |
| 187 | .worker | 205 | .worker |
| 188 | .lock() | 206 | .lock() |
| 189 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; | 207 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; |
| 190 | if owner.upgrade().is_some() { | 208 | let previous = owner.upgrade(); |
| 209 | if let Some(previous) = &previous | ||
| 210 | && !replacing.is_some_and(|worker| { | ||
| 211 | Arc::ptr_eq(previous, &worker.signal) && previous.stopped.load(Ordering::Acquire) | ||
| 212 | }) | ||
| 213 | { | ||
| 191 | return Err(io::ErrorKind::WouldBlock.into()); | 214 | return Err(io::ErrorKind::WouldBlock.into()); |
| 192 | } | 215 | } |
| 193 | let (signal, receiver) = Signal::new(); | 216 | let (signal, receiver) = Signal::new(); |
| 217 | if let Some(previous) = previous { | ||
| 218 | signal | ||
| 219 | .offline | ||
| 220 | .store(previous.offline.load(Ordering::Acquire), Ordering::Release); | ||
| 221 | if let (Ok(previous), Ok(mut next)) = (previous.pause.lock(), signal.pause.lock()) { | ||
| 222 | next.length = previous.length; | ||
| 223 | next.waiting = previous.waiting; | ||
| 224 | } | ||
| 225 | } | ||
| 194 | let replica = Arc::clone(self); | 226 | let replica = Arc::clone(self); |
| 195 | let worker_signal = Arc::clone(&signal); | 227 | let worker_signal = Arc::clone(&signal); |
| 196 | let thread = task::spawn("onestore-sync", move || async move { | 228 | let thread = task::spawn("onestore-sync", move || async move { |
crates/notebook/tests/session.rs+134| ... | @@ -1218,6 +1218,140 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { | ... | @@ -1218,6 +1218,140 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { |
| 1218 | /// A section file whose stamp reads are counted. | 1218 | /// A section file whose stamp reads are counted. |
| 1219 | struct Counted(std::path::PathBuf, Arc<AtomicUsize>); | 1219 | struct Counted(std::path::PathBuf, Arc<AtomicUsize>); |
| 1220 | 1220 | ||
| 1221 | #[test] | ||
| 1222 | fn a_moved_section_keeps_its_replica_while_the_old_remote_is_stalled() { | ||
| 1223 | use std::sync::{Condvar, Mutex, mpsc}; | ||
| 1224 | |||
| 1225 | struct Stalled { | ||
| 1226 | file: std::path::PathBuf, | ||
| 1227 | source: Vec<u8>, | ||
| 1228 | entered: Arc<Mutex<Option<mpsc::Sender<()>>>>, | ||
| 1229 | release: Arc<(Mutex<bool>, Condvar)>, | ||
| 1230 | finished: Option<mpsc::Sender<()>>, | ||
| 1231 | publications: Arc<AtomicUsize>, | ||
| 1232 | late_error: bool, | ||
| 1233 | } | ||
| 1234 | |||
| 1235 | impl Drop for Stalled { | ||
| 1236 | fn drop(&mut self) { | ||
| 1237 | if let Some(finished) = self.finished.take() { | ||
| 1238 | let _ = finished.send(()); | ||
| 1239 | } | ||
| 1240 | } | ||
| 1241 | } | ||
| 1242 | |||
| 1243 | impl notebook::Remote for Stalled { | ||
| 1244 | fn read(&mut self) -> std::io::Result<Vec<u8>> { | ||
| 1245 | Ok(self.source.clone()) | ||
| 1246 | } | ||
| 1247 | |||
| 1248 | fn stamp(&mut self) -> std::io::Result<onestore::Stamp> { | ||
| 1249 | if let Some(entered) = self.entered.lock().unwrap().take() { | ||
| 1250 | entered.send(()).unwrap(); | ||
| 1251 | let (lock, changed) = &*self.release; | ||
| 1252 | let _ = changed | ||
| 1253 | .wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(5), |done| !*done) | ||
| 1254 | .unwrap(); | ||
| 1255 | if self.late_error { | ||
| 1256 | return Err(std::io::ErrorKind::TimedOut.into()); | ||
| 1257 | } | ||
| 1258 | } | ||
| 1259 | onestore::Stamp::of(&self.source).map_err(std::io::Error::other) | ||
| 1260 | } | ||
| 1261 | |||
| 1262 | fn publish( | ||
| 1263 | &mut self, | ||
| 1264 | transaction: &onestore::Transaction, | ||
| 1265 | ) -> Result<(), onestore::CommitError> { | ||
| 1266 | self.publications.fetch_add(1, Ordering::SeqCst); | ||
| 1267 | transaction.commit_file(&self.file) | ||
| 1268 | } | ||
| 1269 | |||
| 1270 | fn confirm(&mut self, _: &onestore::Stamp) -> Result<(), onestore::CommitError> { | ||
| 1271 | Ok(()) | ||
| 1272 | } | ||
| 1273 | } | ||
| 1274 | |||
| 1275 | for late_error in [false, true] { | ||
| 1276 | let directory = tempfile::tempdir().unwrap(); | ||
| 1277 | let file = directory.path().join("Before.one"); | ||
| 1278 | let target = directory.path().join("After.one"); | ||
| 1279 | let source = onestore::create_section("Before.one", "Original", "Author").unwrap(); | ||
| 1280 | std::fs::write(&file, &source).unwrap(); | ||
| 1281 | let (entered, waiting) = mpsc::channel(); | ||
| 1282 | let (connected, connecting) = mpsc::channel(); | ||
| 1283 | let (finished, stopped) = mpsc::channel(); | ||
| 1284 | let begin = Arc::new((Mutex::new(false), Condvar::new())); | ||
| 1285 | let starting = Arc::clone(&begin); | ||
| 1286 | let release = Arc::new((Mutex::new(false), Condvar::new())); | ||
| 1287 | let gate = Arc::clone(&release); | ||
| 1288 | let entered = Arc::new(Mutex::new(Some(entered))); | ||
| 1289 | let publications = Arc::new(AtomicUsize::new(0)); | ||
| 1290 | let attempts = Arc::clone(&publications); | ||
| 1291 | let mut opening = true; | ||
| 1292 | let mut section = Section::open_with( | ||
| 1293 | &file, | ||
| 1294 | directory.path().join("cache"), | ||
| 1295 | move |file| { | ||
| 1296 | let finishing = (!std::mem::replace(&mut opening, false)).then(|| finished.clone()); | ||
| 1297 | if finishing.is_some() { | ||
| 1298 | connected.send(()).unwrap(); | ||
| 1299 | let (lock, changed) = &*starting; | ||
| 1300 | let _ = changed | ||
| 1301 | .wait_timeout_while(lock.lock().unwrap(), Duration::from_secs(5), |done| { | ||
| 1302 | !*done | ||
| 1303 | }) | ||
| 1304 | .unwrap(); | ||
| 1305 | } | ||
| 1306 | Ok(Stalled { | ||
| 1307 | file: file.to_owned(), | ||
| 1308 | source: source.clone(), | ||
| 1309 | entered: Arc::clone(&entered), | ||
| 1310 | release: Arc::clone(&gate), | ||
| 1311 | finished: finishing, | ||
| 1312 | publications: Arc::clone(&attempts), | ||
| 1313 | late_error, | ||
| 1314 | }) | ||
| 1315 | }, | ||
| 1316 | || {}, | ||
| 1317 | ) | ||
| 1318 | .unwrap(); | ||
| 1319 | connecting.recv_timeout(Duration::from_secs(3)).unwrap(); | ||
| 1320 | let replica = Arc::clone(section.replica()); | ||
| 1321 | let (space, ..) = section.pages().unwrap()[0]; | ||
| 1322 | let before = section.page(space).unwrap(); | ||
| 1323 | let edit = typed(&section, space, &before, 0..0, "Moved "); | ||
| 1324 | *begin.0.lock().unwrap() = true; | ||
| 1325 | begin.1.notify_all(); | ||
| 1326 | waiting.recv_timeout(Duration::from_secs(3)).unwrap(); | ||
| 1327 | section.set_offline(true); | ||
| 1328 | std::fs::rename(&file, &target).unwrap(); | ||
| 1329 | let start = Instant::now(); | ||
| 1330 | section.relocate(&target).unwrap(); | ||
| 1331 | assert!(start.elapsed() < Duration::from_secs(1)); | ||
| 1332 | assert!(Arc::ptr_eq(&replica, section.replica())); | ||
| 1333 | assert_eq!(section.file(), target); | ||
| 1334 | assert_same(section.page(space).unwrap(), &edited(&before, "Moved ")); | ||
| 1335 | *release.0.lock().unwrap() = true; | ||
| 1336 | release.1.notify_all(); | ||
| 1337 | stopped.recv_timeout(Duration::from_secs(3)).unwrap(); | ||
| 1338 | assert_eq!( | ||
| 1339 | publications.load(Ordering::SeqCst), | ||
| 1340 | usize::from(!late_error) | ||
| 1341 | ); | ||
| 1342 | assert!(section.sync_status().unwrap().error.is_none()); | ||
| 1343 | assert!(!matches!( | ||
| 1344 | section.status(edit).unwrap(), | ||
| 1345 | Some(EditStatus::Published { .. }) | ||
| 1346 | )); | ||
| 1347 | section.set_offline(false); | ||
| 1348 | published(&section, edit); | ||
| 1349 | assert_same(stored_page(&target, space), &edited(&before, "Moved ")); | ||
| 1350 | assert!(!file.exists()); | ||
| 1351 | section.close().unwrap(); | ||
| 1352 | } | ||
| 1353 | } | ||
| 1354 | |||
| 1221 | impl notebook::Remote for Counted { | 1355 | impl notebook::Remote for Counted { |
| 1222 | fn read(&mut self) -> std::io::Result<Vec<u8>> { | 1356 | fn read(&mut self) -> std::io::Result<Vec<u8>> { |
| 1223 | onestore::read_file(&self.0) | 1357 | onestore::read_file(&self.0) |
crates/snowbound/Cargo.toml+1| ... | @@ -23,6 +23,7 @@ notebook = { path = "../notebook" } | ... | @@ -23,6 +23,7 @@ notebook = { path = "../notebook" } |
| 23 | serde = { version = "1", features = ["derive"] } | 23 | serde = { version = "1", features = ["derive"] } |
| 24 | serde_json = "1" | 24 | serde_json = "1" |
| 25 | pollster = "0.4" | 25 | pollster = "0.4" |
| 26 | async-lock = "3.4.2" | ||
| 26 | wgpu = { workspace = true, optional = true } | 27 | wgpu = { workspace = true, optional = true } |
| 27 | parley.workspace = true | 28 | parley.workspace = true |
| 28 | winit = "0.30.13" | 29 | winit = "0.30.13" |
crates/snowbound/src/library.rs+91| ... | @@ -8,6 +8,8 @@ use notebook::sidecar::themes::Themes; | ... | @@ -8,6 +8,8 @@ use notebook::sidecar::themes::Themes; |
| 8 | use notebook::smb::{Client, Credentials}; | 8 | use notebook::smb::{Client, Credentials}; |
| 9 | use onestore::protected::Key; | 9 | use onestore::protected::Key; |
| 10 | use std::{ | 10 | use std::{ |
| 11 | borrow::Cow, | ||
| 12 | collections::BTreeMap, | ||
| 11 | error::Error, | 13 | error::Error, |
| 12 | io, | 14 | io, |
| 13 | path::{Path, PathBuf}, | 15 | path::{Path, PathBuf}, |
| ... | @@ -324,6 +326,8 @@ pub struct Library { | ... | @@ -324,6 +326,8 @@ pub struct Library { |
| 324 | /// Sections opened ahead of being shown, or left open after, most recent first, which | 326 | /// Sections opened ahead of being shown, or left open after, most recent first, which |
| 325 | /// `open` hands out before opening any. | 327 | /// `open` hands out before opening any. |
| 326 | kept: Mutex<crate::prefetch::Recent<String, Section>>, | 328 | kept: Mutex<crate::prefetch::Recent<String, Section>>, |
| 329 | pub(crate) renames: Arc<Mutex<BTreeMap<[u8; 16], (String, Arc<()>)>>>, | ||
| 330 | pub(crate) structure: Arc<async_lock::Mutex<()>>, | ||
| 327 | themes: Mutex<ThemeCache>, | 331 | themes: Mutex<ThemeCache>, |
| 328 | /// The keys of its password-protected sections unlocked this run, kept as it is read again. | 332 | /// The keys of its password-protected sections unlocked this run, kept as it is read again. |
| 329 | keys: Arc<Keys>, | 333 | keys: Arc<Keys>, |
| ... | @@ -359,6 +363,8 @@ impl Library { | ... | @@ -359,6 +363,8 @@ impl Library { |
| 359 | watch: None, | 363 | watch: None, |
| 360 | tag_art: Default::default(), | 364 | tag_art: Default::default(), |
| 361 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), | 365 | kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), |
| 366 | renames: Arc::default(), | ||
| 367 | structure: Arc::default(), | ||
| 362 | themes: Default::default(), | 368 | themes: Default::default(), |
| 363 | keys: Default::default(), | 369 | keys: Default::default(), |
| 364 | #[cfg(feature = "live")] | 370 | #[cfg(feature = "live")] |
| ... | @@ -796,6 +802,8 @@ impl Library { | ... | @@ -796,6 +802,8 @@ impl Library { |
| 796 | watch, | 802 | watch, |
| 797 | tag_art: Mutex::new(self.tag_art()), | 803 | tag_art: Mutex::new(self.tag_art()), |
| 798 | keys: Arc::clone(&self.keys), | 804 | keys: Arc::clone(&self.keys), |
| 805 | structure: Arc::clone(&self.structure), | ||
| 806 | renames: Arc::clone(&self.renames), | ||
| 799 | #[cfg(feature = "live")] | 807 | #[cfg(feature = "live")] |
| 800 | joined: self.joined.clone(), | 808 | joined: self.joined.clone(), |
| 801 | ..Self::new(&self.location, name, &self.cache) | 809 | ..Self::new(&self.location, name, &self.cache) |
| ... | @@ -882,6 +890,60 @@ impl Library { | ... | @@ -882,6 +890,60 @@ impl Library { |
| 882 | } | 890 | } |
| 883 | } | 891 | } |
| 884 | 892 | ||
| 893 | /// Follows a moved section without closing its readable replica or waiting for the share. | ||
| 894 | pub(crate) fn relocate(&self, path: &str, section: &mut Section) -> Result<(), Box<dyn Error>> { | ||
| 895 | #[cfg(feature = "live")] | ||
| 896 | if let Some(joined) = &self.joined { | ||
| 897 | let guest = Arc::clone(&joined.guest); | ||
| 898 | section.relocate_with(path, move |file| { | ||
| 899 | Ok(notebook::live::share::HostedRemote::new( | ||
| 900 | &guest, | ||
| 901 | &file.to_string_lossy(), | ||
| 902 | )) | ||
| 903 | })?; | ||
| 904 | if let Some(background) = &self.background { | ||
| 905 | background.hold(path, section); | ||
| 906 | } | ||
| 907 | return Ok(()); | ||
| 908 | } | ||
| 909 | match &self.server { | ||
| 910 | #[cfg(not(target_arch = "wasm32"))] | ||
| 911 | Some(server) => { | ||
| 912 | let file = match server.mount.root.as_str() { | ||
| 913 | "" => path.to_owned(), | ||
| 914 | root => format!("{root}/{path}"), | ||
| 915 | }; | ||
| 916 | let server = Arc::clone(server); | ||
| 917 | section.relocate_with(file, move |file| { | ||
| 918 | Ok(notebook::SmbRemote::new( | ||
| 919 | server.connect()?, | ||
| 920 | file.to_string_lossy(), | ||
| 921 | LIMIT, | ||
| 922 | )) | ||
| 923 | })?; | ||
| 924 | } | ||
| 925 | #[cfg(target_arch = "wasm32")] | ||
| 926 | Some(_) => unreachable!("The browser opens no notebook on a server"), | ||
| 927 | None => { | ||
| 928 | let file = Path::new(&self.location).join(path); | ||
| 929 | #[cfg(target_os = "macos")] | ||
| 930 | if self.in_icloud() { | ||
| 931 | section.relocate_with(file, |file| { | ||
| 932 | Ok(crate::icloud::Coordinated(file.to_owned())) | ||
| 933 | })?; | ||
| 934 | } else { | ||
| 935 | section.relocate(file)?; | ||
| 936 | } | ||
| 937 | #[cfg(not(target_os = "macos"))] | ||
| 938 | section.relocate(file)?; | ||
| 939 | } | ||
| 940 | } | ||
| 941 | if let Some(background) = &self.background { | ||
| 942 | background.hold(path, section); | ||
| 943 | } | ||
| 944 | Ok(()) | ||
| 945 | } | ||
| 946 | |||
| 885 | /// Opens a section, waiting natively until `deadline` for its current owner. | 947 | /// Opens a section, waiting natively until `deadline` for its current owner. |
| 886 | fn open_by( | 948 | fn open_by( |
| 887 | &self, | 949 | &self, |
| ... | @@ -1147,6 +1209,35 @@ impl Library { | ... | @@ -1147,6 +1209,35 @@ impl Library { |
| 1147 | key(&self.location, path) | 1209 | key(&self.location, path) |
| 1148 | } | 1210 | } |
| 1149 | 1211 | ||
| 1212 | /// A committed entry label, replaced by its pending rename until storage answers. | ||
| 1213 | pub(crate) fn label<'a>(&self, path: &str, committed: &'a str) -> Cow<'a, str> { | ||
| 1214 | self.renames | ||
| 1215 | .lock() | ||
| 1216 | .ok() | ||
| 1217 | .and_then(|renames| { | ||
| 1218 | renames | ||
| 1219 | .get(&self.entry_identity(path)?) | ||
| 1220 | .map(|(name, _)| name.clone()) | ||
| 1221 | }) | ||
| 1222 | .map_or(Cow::Borrowed(committed), Cow::Owned) | ||
| 1223 | } | ||
| 1224 | |||
| 1225 | pub(crate) fn entry_identity(&self, path: &str) -> Option<[u8; 16]> { | ||
| 1226 | let catalog = self.catalog()?; | ||
| 1227 | catalog | ||
| 1228 | .sections() | ||
| 1229 | .find(|section| section.path == path) | ||
| 1230 | .map(|section| section.file_id) | ||
| 1231 | .or_else(|| { | ||
| 1232 | folders(catalog, |_| true) | ||
| 1233 | .into_iter() | ||
| 1234 | .find(|folder| folder.path == path)? | ||
| 1235 | .toc | ||
| 1236 | .as_ref() | ||
| 1237 | .map(|toc| toc.file_id) | ||
| 1238 | }) | ||
| 1239 | } | ||
| 1240 | |||
| 1150 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. | 1241 | /// The readable sections of the folder at catalog path `folder`, as tabs in its order. |
| 1151 | pub fn tabs(&self, folder: &str) -> Vec<Tab> { | 1242 | pub fn tabs(&self, folder: &str) -> Vec<Tab> { |
| 1152 | match &self.notebook { | 1243 | match &self.notebook { |
crates/snowbound/src/main.rs+61-25| ... | @@ -568,6 +568,22 @@ enum Bar { | ... | @@ -568,6 +568,22 @@ enum Bar { |
| 568 | } | 568 | } |
| 569 | 569 | ||
| 570 | impl Session { | 570 | impl Session { |
| 571 | fn follow(&mut self, library: Arc<Library>, path: &str) -> Result<(), Box<dyn Error>> { | ||
| 572 | let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder); | ||
| 573 | let tabs = library.tabs(folder); | ||
| 574 | let tab = tabs | ||
| 575 | .iter() | ||
| 576 | .position(|tab| tab.path == path) | ||
| 577 | .ok_or("The section is not in its notebook")?; | ||
| 578 | if self.tabs[self.tab].path != path { | ||
| 579 | library.relocate(path, &mut self.section)?; | ||
| 580 | } | ||
| 581 | self.tabs = tabs; | ||
| 582 | self.tab = tab; | ||
| 583 | self.library = library; | ||
| 584 | Ok(()) | ||
| 585 | } | ||
| 586 | |||
| 571 | fn title(&self) -> &str { | 587 | fn title(&self) -> &str { |
| 572 | match self.conflict(self.space) { | 588 | match self.conflict(self.space) { |
| 573 | Some((_, version)) => &version.title, | 589 | Some((_, version)) => &version.title, |
| ... | @@ -1797,6 +1813,11 @@ impl State { | ... | @@ -1797,6 +1813,11 @@ impl State { |
| 1797 | let typed = renaming | 1813 | let typed = renaming |
| 1798 | .as_ref() | 1814 | .as_ref() |
| 1799 | .map(|(tab, name)| (*tab, name.to_string())); | 1815 | .map(|(tab, name)| (*tab, name.to_string())); |
| 1816 | let labels: Vec<_> = session | ||
| 1817 | .tabs | ||
| 1818 | .iter() | ||
| 1819 | .map(|tab| session.library.label(&tab.path, &tab.name)) | ||
| 1820 | .collect(); | ||
| 1800 | let tabs: Vec<_> = session | 1821 | let tabs: Vec<_> = session |
| 1801 | .tabs | 1822 | .tabs |
| 1802 | .iter() | 1823 | .iter() |
| ... | @@ -1804,7 +1825,7 @@ impl State { | ... | @@ -1804,7 +1825,7 @@ impl State { |
| 1804 | .map(|(index, tab)| { | 1825 | .map(|(index, tab)| { |
| 1805 | let name = match &typed { | 1826 | let name = match &typed { |
| 1806 | Some((renamed, name)) if *renamed == index => name.as_str(), | 1827 | Some((renamed, name)) if *renamed == index => name.as_str(), |
| 1807 | _ => tab.name.as_str(), | 1828 | _ => labels[index].as_ref(), |
| 1808 | }; | 1829 | }; |
| 1809 | let unread = unread.contains(&session.library.key(&tab.path)); | 1830 | let unread = unread.contains(&session.library.key(&tab.path)); |
| 1810 | (name, section_color(tab.color), unread) | 1831 | (name, section_color(tab.color), unread) |
| ... | @@ -3848,7 +3869,35 @@ impl State { | ... | @@ -3848,7 +3869,35 @@ impl State { |
| 3848 | } | 3869 | } |
| 3849 | let created = matches!(opening.loaded, Shown::Created(_)); | 3870 | let created = matches!(opening.loaded, Shown::Created(_)); |
| 3850 | match opening.loaded { | 3871 | match opening.loaded { |
| 3851 | Shown::Section(session) | Shown::Created(session) => { | 3872 | Shown::Section(mut session) | Shown::Created(mut session) => { |
| 3873 | if let Some(library) = self | ||
| 3874 | .notebooks | ||
| 3875 | .iter() | ||
| 3876 | .find(|listed| { | ||
| 3877 | listed.location == session.library.location | ||
| 3878 | && Arc::ptr_eq(&listed.structure, &session.library.structure) | ||
| 3879 | && !Arc::ptr_eq(listed, &session.library) | ||
| 3880 | }) | ||
| 3881 | .cloned() | ||
| 3882 | { | ||
| 3883 | let identity = session | ||
| 3884 | .library | ||
| 3885 | .section_identity(&session.tabs[session.tab].path); | ||
| 3886 | let path = library.catalog().and_then(|catalog| { | ||
| 3887 | catalog | ||
| 3888 | .sections() | ||
| 3889 | .find(|section| Some(section.file_id) == identity) | ||
| 3890 | .map(|section| section.path.clone()) | ||
| 3891 | }); | ||
| 3892 | if let Some(path) = path { | ||
| 3893 | session.follow(library, &path)?; | ||
| 3894 | } else if let Some(path) = library.first_section() { | ||
| 3895 | return self.apply(Command::OpenSection(library, path)); | ||
| 3896 | } else { | ||
| 3897 | self.switching = None; | ||
| 3898 | return self.adopt(library, None); | ||
| 3899 | } | ||
| 3900 | } | ||
| 3852 | // The settings keep the notebook shown, not the section. | 3901 | // The settings keep the notebook shown, not the section. |
| 3853 | let other_notebook = self | 3902 | let other_notebook = self |
| 3854 | .session | 3903 | .session |
| ... | @@ -3954,7 +4003,7 @@ impl State { | ... | @@ -3954,7 +4003,7 @@ impl State { |
| 3954 | } | 4003 | } |
| 3955 | 4004 | ||
| 3956 | /// Takes `library`, a notebook read again after a change, in place of the one it was; | 4005 | /// Takes `library`, a notebook read again after a change, in place of the one it was; |
| 3957 | /// the open section, now at catalog `path` in it, stays open, reopened where it moved. | 4006 | /// the open section, now at catalog `path` in it, keeps its replica where it moved. |
| 3958 | /// Without `path` the notebook has no sections left, and one of its shows none. | 4007 | /// Without `path` the notebook has no sections left, and one of its shows none. |
| 3959 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { | 4008 | fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> { |
| 3960 | self.list(&library); | 4009 | self.list(&library); |
| ... | @@ -3968,7 +4017,11 @@ impl State { | ... | @@ -3968,7 +4017,11 @@ impl State { |
| 3968 | { | 4017 | { |
| 3969 | self.persist()?; | 4018 | self.persist()?; |
| 3970 | if let Some(session) = self.session.take() { | 4019 | if let Some(session) = self.session.take() { |
| 3971 | session.section.close()?; | 4020 | crate::spawn_async(move || async move { |
| 4021 | if let Err(error) = session.section.close_async().await { | ||
| 4022 | eprintln!("Synchronization stopped: {error}"); | ||
| 4023 | } | ||
| 4024 | }); | ||
| 3972 | } | 4025 | } |
| 3973 | self.sectionless = Some(Arc::clone(&library)); | 4026 | self.sectionless = Some(Arc::clone(&library)); |
| 3974 | self.title(); | 4027 | self.title(); |
| ... | @@ -3979,28 +4032,11 @@ impl State { | ... | @@ -3979,28 +4032,11 @@ impl State { |
| 3979 | let Some(session) = &mut self.session else { | 4032 | let Some(session) = &mut self.session else { |
| 3980 | return Ok(()); | 4033 | return Ok(()); |
| 3981 | }; | 4034 | }; |
| 3982 | let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder); | 4035 | if !shown(&session.library) { |
| 3983 | let space = session.space; | 4036 | return Ok(()); |
| 3984 | if session.tabs[session.tab].path == path { | ||
| 3985 | session.tabs = library.tabs(folder); | ||
| 3986 | session.tab = session | ||
| 3987 | .tabs | ||
| 3988 | .iter() | ||
| 3989 | .position(|tab| tab.path == path) | ||
| 3990 | .ok_or("The section is not in its notebook")?; | ||
| 3991 | session.library = library; | ||
| 3992 | } else { | ||
| 3993 | // Its file moved: the replica it holds reopens on the file's new path. | ||
| 3994 | self.persist()?; | ||
| 3995 | if let Some(session) = self.session.take() { | ||
| 3996 | session.section.close()?; | ||
| 3997 | } | ||
| 3998 | let section = library.open(path, notify(self.proxy.clone()))?; | ||
| 3999 | let (session, page) = read_session(section, library, path.to_owned(), Some(space))?; | ||
| 4000 | self.session = Some(session); | ||
| 4001 | let response = self.view.refresh(page)?; | ||
| 4002 | self.respond(response); | ||
| 4003 | } | 4037 | } |
| 4038 | session.follow(library, path)?; | ||
| 4039 | self.title(); | ||
| 4004 | self.save_settings(); | 4040 | self.save_settings(); |
| 4005 | Ok(()) | 4041 | Ok(()) |
| 4006 | } | 4042 | } |
crates/snowbound/src/manage.rs+228-4| ... | @@ -111,6 +111,63 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { | ... | @@ -111,6 +111,63 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { |
| 111 | .expect("Some number is free") | 111 | .expect("Some number is free") |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | fn rename( | ||
| 115 | notebook: &mut Notebook, | ||
| 116 | library: &Library, | ||
| 117 | path: &str, | ||
| 118 | name: &str, | ||
| 119 | request: Option<&([u8; 16], Arc<()>)>, | ||
| 120 | ) -> Result<String, Box<dyn Error>> { | ||
| 121 | let current = request | ||
| 122 | .and_then(|(identity, _)| { | ||
| 123 | notebook | ||
| 124 | .catalog() | ||
| 125 | .sections() | ||
| 126 | .find(|section| &section.file_id == identity) | ||
| 127 | .map(|section| section.path.clone()) | ||
| 128 | .or_else(|| { | ||
| 129 | crate::library::folders(notebook.catalog(), |_| true) | ||
| 130 | .into_iter() | ||
| 131 | .find(|folder| { | ||
| 132 | folder | ||
| 133 | .toc | ||
| 134 | .as_ref() | ||
| 135 | .is_some_and(|toc| &toc.file_id == identity) | ||
| 136 | }) | ||
| 137 | .map(|folder| folder.path.clone()) | ||
| 138 | }) | ||
| 139 | }) | ||
| 140 | .unwrap_or_else(|| path.to_owned()); | ||
| 141 | let latest = request.is_none_or(|(identity, request)| { | ||
| 142 | library | ||
| 143 | .renames | ||
| 144 | .lock() | ||
| 145 | .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| 146 | .get(identity) | ||
| 147 | .is_some_and(|(_, pending)| Arc::ptr_eq(pending, request)) | ||
| 148 | }); | ||
| 149 | if latest && crate::library::entry_name(&current) != name { | ||
| 150 | Ok(notebook.rename(&current, name)?) | ||
| 151 | } else { | ||
| 152 | Ok(current) | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | fn finish_rename(library: &Library, request: Option<([u8; 16], Arc<()>)>) { | ||
| 157 | if let Some((identity, request)) = request { | ||
| 158 | let mut renames = library | ||
| 159 | .renames | ||
| 160 | .lock() | ||
| 161 | .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| 162 | if renames | ||
| 163 | .get(&identity) | ||
| 164 | .is_some_and(|(_, pending)| Arc::ptr_eq(pending, &request)) | ||
| 165 | { | ||
| 166 | renames.remove(&identity); | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 114 | impl State { | 171 | impl State { |
| 115 | /// A page OneNote 2010 would create now, titled `title` in the Default font's face, with | 172 | /// A page OneNote 2010 would create now, titled `title` in the Default font's face, with |
| 116 | /// its date and time. | 173 | /// its date and time. |
| ... | @@ -781,7 +838,24 @@ impl State { | ... | @@ -781,7 +838,24 @@ impl State { |
| 781 | .filter(|session| Arc::ptr_eq(&session.library, &library)) | 838 | .filter(|session| Arc::ptr_eq(&session.library, &library)) |
| 782 | .map(|session| session.tabs[session.tab].path.clone()); | 839 | .map(|session| session.tabs[session.tab].path.clone()); |
| 783 | let notify = notify(self.proxy.clone()); | 840 | let notify = notify(self.proxy.clone()); |
| 784 | self.load_async(move || async move { | 841 | let (proxy, requested) = (self.proxy.clone(), self.loading); |
| 842 | let showing = self.switching.is_none() && self.opening.is_none(); | ||
| 843 | let structure = Arc::clone(&library.structure); | ||
| 844 | let pending = match &change { | ||
| 845 | Structure::Rename { path, name } => library.entry_identity(path).map(|identity| { | ||
| 846 | let request = Arc::new(()); | ||
| 847 | library | ||
| 848 | .renames | ||
| 849 | .lock() | ||
| 850 | .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| 851 | .insert(identity, (name.clone(), Arc::clone(&request))); | ||
| 852 | (identity, request) | ||
| 853 | }), | ||
| 854 | _ => None, | ||
| 855 | }; | ||
| 856 | let renaming = Arc::clone(&library); | ||
| 857 | let request = pending.clone(); | ||
| 858 | let read = move || async move { | ||
| 785 | // A section kept open would hold a file the change moves. | 859 | // A section kept open would hold a file the change moves. |
| 786 | library.close_kept_async().await?; | 860 | library.close_kept_async().await?; |
| 787 | let mut notebook = library.reopen()?; | 861 | let mut notebook = library.reopen()?; |
| ... | @@ -817,7 +891,7 @@ impl State { | ... | @@ -817,7 +891,7 @@ impl State { |
| 817 | (current, None) | 891 | (current, None) |
| 818 | } | 892 | } |
| 819 | Structure::Rename { path, name } => { | 893 | Structure::Rename { path, name } => { |
| 820 | let renamed = notebook.rename(&path, &name)?; | 894 | let renamed = rename(&mut notebook, &library, &path, &name, request.as_ref())?; |
| 821 | ( | 895 | ( |
| 822 | current.map(|current| follow(&current, &path, &renamed)), | 896 | current.map(|current| follow(&current, &path, &renamed)), |
| 823 | None, | 897 | None, |
| ... | @@ -892,8 +966,7 @@ impl State { | ... | @@ -892,8 +966,7 @@ impl State { |
| 892 | let Some(path) = shown(&library, open) else { | 966 | let Some(path) = shown(&library, open) else { |
| 893 | return Ok(Loaded::Library(library, None)); | 967 | return Ok(Loaded::Library(library, None)); |
| 894 | }; | 968 | }; |
| 895 | // The open section, perhaps renamed or moved, stays open: its replica is this | 969 | // The shown section keeps its replica when its file moves. |
| 896 | // thread's to reopen only where no section holds it. | ||
| 897 | if followed.as_deref() == Some(path.as_str()) { | 970 | if followed.as_deref() == Some(path.as_str()) { |
| 898 | return Ok(Loaded::Library(library, Some(path))); | 971 | return Ok(Loaded::Library(library, Some(path))); |
| 899 | } | 972 | } |
| ... | @@ -904,6 +977,75 @@ impl State { | ... | @@ -904,6 +977,75 @@ impl State { |
| 904 | } else { | 977 | } else { |
| 905 | Loaded::Section(Box::new(session), page) | 978 | Loaded::Section(Box::new(session), page) |
| 906 | }) | 979 | }) |
| 980 | }; | ||
| 981 | crate::spawn_async(move || async move { | ||
| 982 | let _changing = structure.lock().await; | ||
| 983 | let loaded: Result<Loaded, Box<dyn Error>> = read().await; | ||
| 984 | let loaded = loaded.map_err(|error| crate::plain(&*error, "page")); | ||
| 985 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 986 | finish_rename(&renaming, pending); | ||
| 987 | let loaded = match loaded { | ||
| 988 | Ok(loaded) => loaded, | ||
| 989 | Err(error) => { | ||
| 990 | platform::alert("Couldn't open", &error); | ||
| 991 | return Ok(()); | ||
| 992 | } | ||
| 993 | }; | ||
| 994 | let library = match &loaded { | ||
| 995 | Loaded::Library(library, _) => Arc::clone(library), | ||
| 996 | Loaded::Section(session, _) | Loaded::Created(session, _) => { | ||
| 997 | Arc::clone(&session.library) | ||
| 998 | } | ||
| 999 | _ => unreachable!("A structure change returns its notebook"), | ||
| 1000 | }; | ||
| 1001 | if !state | ||
| 1002 | .notebooks | ||
| 1003 | .iter() | ||
| 1004 | .any(|listed| listed.location == library.location) | ||
| 1005 | { | ||
| 1006 | return Ok(()); | ||
| 1007 | } | ||
| 1008 | state.list(&library); | ||
| 1009 | let current = state | ||
| 1010 | .session | ||
| 1011 | .as_ref() | ||
| 1012 | .filter(|session| session.library.location == library.location) | ||
| 1013 | .and_then(|session| { | ||
| 1014 | session | ||
| 1015 | .library | ||
| 1016 | .section_identity(&session.tabs[session.tab].path) | ||
| 1017 | }); | ||
| 1018 | if let Some(identity) = current { | ||
| 1019 | let path = library.catalog().and_then(|catalog| { | ||
| 1020 | catalog | ||
| 1021 | .sections() | ||
| 1022 | .find(|section| section.file_id == identity) | ||
| 1023 | .map(|section| section.path.clone()) | ||
| 1024 | }); | ||
| 1025 | if let Some(path) = path { | ||
| 1026 | state.adopt(library, Some(&path))?; | ||
| 1027 | } else if requested != state.loading || !showing { | ||
| 1028 | if let Some(path) = library.first_section() { | ||
| 1029 | state.commands.push(Command::OpenSection(library, path)); | ||
| 1030 | } else { | ||
| 1031 | state.adopt(library, None)?; | ||
| 1032 | } | ||
| 1033 | } | ||
| 1034 | } else if state | ||
| 1035 | .sectionless | ||
| 1036 | .as_ref() | ||
| 1037 | .is_some_and(|shown| shown.location == library.location) | ||
| 1038 | { | ||
| 1039 | state.sectionless = Some(library); | ||
| 1040 | } | ||
| 1041 | if showing && requested == state.loading { | ||
| 1042 | match loaded { | ||
| 1043 | Loaded::Library(library, path) => state.adopt(library, path.as_deref())?, | ||
| 1044 | loaded => state.load(move || Ok(loaded)), | ||
| 1045 | } | ||
| 1046 | } | ||
| 1047 | Ok(()) | ||
| 1048 | }))); | ||
| 907 | }); | 1049 | }); |
| 908 | } | 1050 | } |
| 909 | 1051 | ||
| ... | @@ -1018,6 +1160,88 @@ mod tests { | ... | @@ -1018,6 +1160,88 @@ mod tests { |
| 1018 | creation.dated(&date, &time).unwrap() | 1160 | creation.dated(&date, &time).unwrap() |
| 1019 | } | 1161 | } |
| 1020 | 1162 | ||
| 1163 | #[test] | ||
| 1164 | fn repeated_section_renames_follow_identity_and_keep_the_latest_label() { | ||
| 1165 | let temporary = | ||
| 1166 | std::env::temp_dir().join(format!("snowbound-rename-order-{}", std::process::id())); | ||
| 1167 | let _ = notebook::fs::remove_dir_all(&temporary); | ||
| 1168 | notebook::fs::create_dir_all(&temporary).unwrap(); | ||
| 1169 | let root = temporary.join("Notebook"); | ||
| 1170 | let cache = temporary.join("cache"); | ||
| 1171 | let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); | ||
| 1172 | notebook.create_section("", "Taken", &dated()).unwrap(); | ||
| 1173 | let library = Library::created(root.to_str().unwrap(), notebook, &cache); | ||
| 1174 | let original = "New Section 1.one"; | ||
| 1175 | let identity = library.entry_identity(original).unwrap(); | ||
| 1176 | let pending = |library: &Library, name: &str| { | ||
| 1177 | let request = Arc::new(()); | ||
| 1178 | library | ||
| 1179 | .renames | ||
| 1180 | .lock() | ||
| 1181 | .unwrap() | ||
| 1182 | .insert(identity, (name.to_owned(), Arc::clone(&request))); | ||
| 1183 | (identity, request) | ||
| 1184 | }; | ||
| 1185 | let first = pending(&library, "B"); | ||
| 1186 | let mut notebook = library.reopen().unwrap(); | ||
| 1187 | assert_eq!( | ||
| 1188 | rename(&mut notebook, &library, original, "B", Some(&first)).unwrap(), | ||
| 1189 | "B.one" | ||
| 1190 | ); | ||
| 1191 | let refreshed = library.with(notebook); | ||
| 1192 | let failed = pending(&refreshed, "Taken"); | ||
| 1193 | let mut notebook = refreshed.reopen().unwrap(); | ||
| 1194 | assert!(rename(&mut notebook, &library, original, "Taken", Some(&failed)).is_err()); | ||
| 1195 | let latest = pending(&library, "C"); | ||
| 1196 | finish_rename(&library, Some(first)); | ||
| 1197 | finish_rename(&refreshed, Some(failed)); | ||
| 1198 | assert_eq!(library.label(original, "New Section 1"), "C"); | ||
| 1199 | assert_eq!(refreshed.label("B.one", "B"), "C"); | ||
| 1200 | assert_eq!( | ||
| 1201 | rename(&mut notebook, &library, original, "C", Some(&latest)).unwrap(), | ||
| 1202 | "C.one" | ||
| 1203 | ); | ||
| 1204 | let refreshed = refreshed.with(notebook); | ||
| 1205 | assert_eq!(refreshed.entry_identity("C.one"), Some(identity)); | ||
| 1206 | assert!(!root.join(original).exists()); | ||
| 1207 | assert!(!root.join("B.one").exists()); | ||
| 1208 | assert!(root.join("C.one").exists()); | ||
| 1209 | finish_rename(&library, Some(latest)); | ||
| 1210 | assert!(matches!( | ||
| 1211 | refreshed.label("C.one", "C"), | ||
| 1212 | std::borrow::Cow::Borrowed("C") | ||
| 1213 | )); | ||
| 1214 | |||
| 1215 | let mut notebook = refreshed.reopen().unwrap(); | ||
| 1216 | for stale_name in ["Old", "C"] { | ||
| 1217 | let stale = pending(&library, stale_name); | ||
| 1218 | let same = pending(&refreshed, "C"); | ||
| 1219 | assert_eq!( | ||
| 1220 | rename(&mut notebook, &library, original, stale_name, Some(&stale)).unwrap(), | ||
| 1221 | "C.one" | ||
| 1222 | ); | ||
| 1223 | finish_rename(&library, Some(stale)); | ||
| 1224 | assert!( | ||
| 1225 | library | ||
| 1226 | .renames | ||
| 1227 | .lock() | ||
| 1228 | .unwrap() | ||
| 1229 | .get(&identity) | ||
| 1230 | .is_some_and(|(_, request)| Arc::ptr_eq(request, &same.1)) | ||
| 1231 | ); | ||
| 1232 | assert_eq!( | ||
| 1233 | rename(&mut notebook, &library, original, "C", Some(&same)).unwrap(), | ||
| 1234 | "C.one" | ||
| 1235 | ); | ||
| 1236 | finish_rename(&refreshed, Some(same)); | ||
| 1237 | } | ||
| 1238 | assert!(library.renames.lock().unwrap().is_empty()); | ||
| 1239 | drop(notebook); | ||
| 1240 | drop(refreshed); | ||
| 1241 | drop(library); | ||
| 1242 | notebook::fs::remove_dir_all(temporary).unwrap(); | ||
| 1243 | } | ||
| 1244 | |||
| 1021 | /// Applies `ops` to the section file at `file` as one edit, as the section thread does, | 1245 | /// Applies `ops` to the section file at `file` as one edit, as the section thread does, |
| 1022 | /// and publishes it. | 1246 | /// and publishes it. |
| 1023 | fn edit(file: &Path, ops: Vec<Op>) { | 1247 | fn edit(file: &Path, ops: Vec<Op>) { |
crates/snowbound/src/sidebar.rs+6-2| ... | @@ -417,6 +417,7 @@ fn folder( | ... | @@ -417,6 +417,7 @@ fn folder( |
| 417 | false, | 417 | false, |
| 418 | ), | 418 | ), |
| 419 | }; | 419 | }; |
| 420 | let name = library.label(&section.path, &name); | ||
| 420 | gap(ui, tree, &folder.path, Some(&section.path)); | 421 | gap(ui, tree, &folder.path, Some(&section.path)); |
| 421 | let renaming = renamed(tree, &section.path); | 422 | let renaming = renamed(tree, &section.path); |
| 422 | let id = ui.id(("section", notebook, &section.path)); | 423 | let id = ui.id(("section", notebook, &section.path)); |
| ... | @@ -463,12 +464,15 @@ fn folder( | ... | @@ -463,12 +464,15 @@ fn folder( |
| 463 | } | 464 | } |
| 464 | let key = library.key(&group.path); | 465 | let key = library.key(&group.path); |
| 465 | let unfolded = !tree.folded.contains(&key); | 466 | let unfolded = !tree.folded.contains(&key); |
| 466 | let name = group.path.rsplit('/').next().unwrap_or_default(); | 467 | let name = library.label( |
| 468 | &group.path, | ||
| 469 | group.path.rsplit('/').next().unwrap_or_default(), | ||
| 470 | ); | ||
| 467 | gap(ui, tree, &folder.path, Some(&group.path)); | 471 | gap(ui, tree, &folder.path, Some(&group.path)); |
| 468 | let renaming = renamed(tree, &group.path); | 472 | let renaming = renamed(tree, &group.path); |
| 469 | let id = ui.id(("group", notebook, &group.path)); | 473 | let id = ui.id(("group", notebook, &group.path)); |
| 470 | let row = Row { | 474 | let row = Row { |
| 471 | label: name, | 475 | label: &name, |
| 472 | icon: Leading::Icon(art::SECTION_GROUP), | 476 | icon: Leading::Icon(art::SECTION_GROUP), |
| 473 | depth, | 477 | depth, |
| 474 | dim: false, | 478 | dim: false, |