//! Folders on the library's server: listing one, and making one. //! //! The in-app folder browser needs both, and needs them off the UI thread — //! each is a WebDAV round trip (`PROPFIND`, `MKCOL`) that takes as long as the //! network does. The result comes back on the UI thread through a channel a //! Slint timer drains, the pattern every other worker here uses. //! //! Making a folder then lists its parent again rather than inserting the name //! locally: the server may have normalised it, or refused it, and the list //! the user picks from should be the server's, not this app's guess at it. use crate::executors::{self, Executor}; use std::cell::RefCell; use std::rc::Rc; use std::sync::mpsc::TryRecvError; use std::time::Duration; use dr_sync::{Connection, EntryKind, RemotePath}; /// Child folder names at `path` (empty is the account root), sorted as a /// person reads them. pub fn list( conn: Connection, path: String, done: impl FnOnce(Result, String>) + 'static, ) { run(conn, done, move |backend| { Box::pin(async move { children(backend.as_ref(), &path).await }) }); } /// Make `name` inside `parent`, then list `parent` again. /// /// Refuses a name that would reach anywhere but one level down — a `/`, a /// `..`, nothing at all — before any request is made: a folder the user /// typed a slash into is a path they did not mean. pub fn make( conn: Connection, parent: String, name: String, done: impl FnOnce(Result, String>) + 'static, ) { let name = name.trim().to_string(); if let Err(e) = check_name(&name) { done(Err(e)); return; } run(conn, done, move |backend| { Box::pin(async move { let target = if parent.is_empty() { name.clone() } else { format!("{parent}/{name}") }; backend .create_dir(&RemotePath::new(&target)) .await .map_err(|e| format!("Could not make “{name}”: {e}"))?; children(backend.as_ref(), &parent).await }) }); } /// Whether `name` is one folder name, as [`make`] requires. pub fn check_name(name: &str) -> Result<(), String> { if name.is_empty() { return Err("A folder needs a name.".into()); } if name == "." || name == ".." || name.contains('/') || name.contains('\\') { return Err(format!("“{name}” is not a folder name.")); } Ok(()) } async fn children(backend: &dyn dr_sync::RemoteBackend, path: &str) -> Result, String> { let entries = backend .list(&RemotePath::new(path), None) .await .map_err(|e| e.to_string())?; let mut dirs: Vec = entries .iter() .filter(|e| e.kind == EntryKind::Directory) .map(|e| e.path.name().to_string()) .collect(); dirs.sort_by_key(|d| d.to_ascii_lowercase()); Ok(dirs) } type Work = Box< dyn FnOnce( Box, ) -> std::pin::Pin< Box, String>> + Send>, > + Send, >; /// Connect, do `work`, and hand its answer to `done` on the UI thread. fn run(conn: Connection, done: impl FnOnce(Result, String>) + 'static, work: W) where W: FnOnce( Box, ) -> std::pin::Pin< Box, String>> + Send>, > + Send + 'static, { let work: Work = Box::new(work); let (tx, rx) = std::sync::mpsc::channel::, String>>(); executors::spawn(Executor::Network, "remote-dirs", move || { // Multi-thread, for the reason the login worker records: a // current-thread runtime left reqwest's connection future unpolled on // Android, and the await never resolved. let rt = crate::net_runtime::build(); let Ok(rt) = rt else { let _ = tx.send(Err("could not start the network runtime".into())); return; }; let result = rt.block_on(async { match crate::remote::connect(&conn) { Ok(backend) => work(backend).await, Err(e) => Err(e.to_string()), } }); let _ = tx.send(result); }); // The timer owns itself until the answer arrives, then lets go: nothing // else has a reason to hold it, and a dropped `Timer` stops. let slot: Rc>> = Rc::new(RefCell::new(None)); let mut done = Some(done); let timer = slint::Timer::default(); let held = slot.clone(); timer.start( slint::TimerMode::Repeated, Duration::from_millis(100), move || { let outcome = match rx.try_recv() { Ok(result) => result, Err(TryRecvError::Empty) => return, // A worker that panicked drops the sender without sending, and // treating that as "still loading" is how a picker sits on // "Loading…" for ever. Err(TryRecvError::Disconnected) => { Err("the folder request failed unexpectedly".into()) } }; if let Some(done) = done.take() { done(outcome); } // Stopped here, released a turn later: a timer dropped inside its // own callback is freed while Slint is still calling it. if let Some(t) = held.borrow().as_ref() { t.stop(); } let held = held.clone(); slint::Timer::single_shot(Duration::ZERO, move || { held.borrow_mut().take(); }); }, ); *slot.borrow_mut() = Some(timer); } #[cfg(test)] mod tests { use super::check_name; #[test] fn a_folder_name_is_one_level() { assert!(check_name("Web 2026").is_ok()); assert!(check_name("").is_err()); assert!(check_name("..").is_err()); assert!(check_name("a/b").is_err()); assert!(check_name("a\\b").is_err()); } }