diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 79c7397..131237b 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -342,6 +342,56 @@ fn wire_folder_picker_navigation(window: &AppWindow, controller: &Rc c, + Err(e) => { + ctl.model.borrow_mut().fail(format!("credentials: {e}")); + render(&w, &ctl); + return; + } + }; + let name = name.trim().to_string(); + let child = if parent.is_empty() { + name.clone() + } else { + format!("{parent}/{name}") + }; + ctl.model.borrow_mut().browse_to(parent.clone()); + render(&w, &ctl); + // Made, then walked into: a folder somebody has just named is + // the one they mean to choose. + let (weak, ctl) = (weak.clone(), ctl.clone()); + crate::remote_folders::make(conn, parent, name, move |result| { + let Some(w) = weak.upgrade() else { return }; + match result { + Ok(_) => { + ctl.model.borrow_mut().browse_to(child.clone()); + render(&w, &ctl); + spawn_folder_list(w.as_weak(), ctl.clone(), child); + } + Err(e) => { + ctl.model.borrow_mut().close_browser(); + ctl.model.borrow_mut().fail(e); + render(&w, &ctl); + } + } + }); + }); + } { let weak = window.as_weak(); let ctl = controller.clone(); diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index d32fdd3..d04459a 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -66,6 +66,7 @@ mod presets; mod recovery_ui; mod refine; mod remote; +mod remote_folders; pub mod repairs; mod segmentation; mod settings_store; diff --git a/ui/dr-ui/src/remote_folders.rs b/ui/dr-ui/src/remote_folders.rs new file mode 100644 index 0000000..c749107 --- /dev/null +++ b/ui/dr-ui/src/remote_folders.rs @@ -0,0 +1,169 @@ +//! 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 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>>(); + std::thread::spawn(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 = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_io() + .enable_time() + .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()); + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index bc2891a..b089e03 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -320,6 +320,7 @@ export component AppWindow inherits Window { callback launch-browse-up(); callback launch-browse-confirm(); callback launch-browse-cancel(); + callback launch-browse-make(string); // --- library grid (FR-CAT-4) --- // @@ -1374,6 +1375,7 @@ in property panel-visible: true; browse-up() => { root.launch-browse-up(); } browse-confirm() => { root.launch-browse-confirm(); } browse-cancel() => { root.launch-browse-cancel(); } + browse-make(name) => { root.launch-browse-make(name); } } // The library view sits between the launch screen and develop: a library diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index a3e2c7d..679950f 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -124,6 +124,11 @@ export component LaunchScreen inherits Rectangle { callback browse-up(); callback browse-confirm(); callback browse-cancel(); + /// Make a folder here, by name, and go into it. + callback browse-make(string); + + // Whether the picker is asking for a new folder's name. + property naming-folder: false; background: Theme.ground; @@ -393,10 +398,51 @@ export component LaunchScreen inherits Rectangle { } // Current location, so it is always clear what - // "Use this folder" would select. - Value { - text: root.browse-path == "" ? "/" : "/" + root.browse-path; - overflow: elide; + // "Use this folder" would select — and beside it the way + // to make one, for a library that is not on the server + // yet. + HorizontalLayout { + spacing: Theme.gap; + Value { + text: root.browse-path == "" ? "/" : "/" + root.browse-path; + overflow: elide; + horizontal-stretch: 1; + min-width: 0px; + vertical-alignment: center; + } + FormButton { + text: @tr("New folder"); + enabled: !root.browse-loading && !root.naming-folder; + clicked => { root.naming-folder = true; } + } + } + + if root.naming-folder: HorizontalLayout { + spacing: Theme.gap; + new-name := Field { + horizontal-stretch: 1; + label: @tr("New folder name"); + placeholder: @tr("Folder name"); + accepted(t) => { + if (t != "") { + root.browse-make(t); + root.naming-folder = false; + } + } + init => { self.take-focus(); } + } + FormButton { + text: @tr("Make"); + enabled: new-name.text != ""; + clicked => { + root.browse-make(new-name.text); + root.naming-folder = false; + } + } + FormButton { + text: @tr("Cancel"); + clicked => { root.naming-folder = false; } + } } // Not a `Panel`: this box overlays three mutually