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
+50
View File
@@ -342,6 +342,56 @@ fn wire_folder_picker_navigation(window: &AppWindow, controller: &Rc<LaunchContr
render(&w, &ctl); render(&w, &ctl);
}); });
} }
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_browse_make(move |name| {
let Some(w) = weak.upgrade() else { return };
let (account, parent) = {
let m = ctl.model.borrow();
let Some(b) = m.browser.as_ref() else { return };
let Some(account) = m.session().cloned() else { return };
(account, b.path.clone())
};
let conn = match ctl
.store
.connection(&account, crate::remote::needs_secret(&account))
{
Ok(c) => 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 weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
+1
View File
@@ -66,6 +66,7 @@ mod presets;
mod recovery_ui; mod recovery_ui;
mod refine; mod refine;
mod remote; mod remote;
mod remote_folders;
pub mod repairs; pub mod repairs;
mod segmentation; mod segmentation;
mod settings_store; mod settings_store;
+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());
}
}
+2
View File
@@ -320,6 +320,7 @@ export component AppWindow inherits Window {
callback launch-browse-up(); callback launch-browse-up();
callback launch-browse-confirm(); callback launch-browse-confirm();
callback launch-browse-cancel(); callback launch-browse-cancel();
callback launch-browse-make(string);
// --- library grid (FR-CAT-4) --- // --- library grid (FR-CAT-4) ---
// //
@@ -1374,6 +1375,7 @@ in property <bool> panel-visible: true;
browse-up() => { root.launch-browse-up(); } browse-up() => { root.launch-browse-up(); }
browse-confirm() => { root.launch-browse-confirm(); } browse-confirm() => { root.launch-browse-confirm(); }
browse-cancel() => { root.launch-browse-cancel(); } 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 // The library view sits between the launch screen and develop: a library
+50 -4
View File
@@ -124,6 +124,11 @@ export component LaunchScreen inherits Rectangle {
callback browse-up(); callback browse-up();
callback browse-confirm(); callback browse-confirm();
callback browse-cancel(); 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 <bool> naming-folder: false;
background: Theme.ground; background: Theme.ground;
@@ -393,10 +398,51 @@ export component LaunchScreen inherits Rectangle {
} }
// Current location, so it is always clear what // Current location, so it is always clear what
// "Use this folder" would select. // "Use this folder" would select — and beside it the way
Value { // to make one, for a library that is not on the server
text: root.browse-path == "" ? "/" : "/" + root.browse-path; // yet.
overflow: elide; 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 // Not a `Panel`: this box overlays three mutually