Make a folder from the server browser

The in-app browser that chooses a library folder on the server could
only open folders that already existed, so a library, or an export
destination, that was not on the server yet had to be made in the
Nextcloud web page first. It now has "New folder": a name, then MKCOL,
then the parent listed again and the new folder walked into — a folder
somebody has just named is the one they mean to choose.

The listing is the server's rather than the name inserted locally: the
server may have normalised or refused it. A name with a slash, "..",
or nothing at all is refused before any request, because a folder
typed with a slash in it is a path the user did not mean.

remote_folders holds the two WebDAV round trips (list, make) off the UI
thread, with the answer delivered through a Slint timer, so the album
sheet can use the same browser.
This commit is contained in:
2026-09-26 14:13:53 -04:00
parent 6683c14b40
commit 2eb06b1064
5 changed files with 272 additions and 4 deletions
+169
View File
@@ -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<Vec<String>, 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<Vec<String>, 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<Vec<String>, String> {
let entries = backend
.list(&RemotePath::new(path), None)
.await
.map_err(|e| e.to_string())?;
let mut dirs: Vec<String> = 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<dyn dr_sync::RemoteBackend>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<String>, String>> + Send>,
> + Send,
>;
/// Connect, do `work`, and hand its answer to `done` on the UI thread.
fn run<W>(conn: Connection, done: impl FnOnce(Result<Vec<String>, String>) + 'static, work: W)
where
W: FnOnce(
Box<dyn dr_sync::RemoteBackend>,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<String>, String>> + Send>,
> + Send
+ 'static,
{
let work: Work = Box::new(work);
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, 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<RefCell<Option<slint::Timer>>> = 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());
}
}