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:
@@ -342,6 +342,56 @@ fn wire_folder_picker_navigation(window: &AppWindow, controller: &Rc<LaunchContr
|
||||
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 ctl = controller.clone();
|
||||
|
||||
@@ -66,6 +66,7 @@ mod presets;
|
||||
mod recovery_ui;
|
||||
mod refine;
|
||||
mod remote;
|
||||
mod remote_folders;
|
||||
pub mod repairs;
|
||||
mod segmentation;
|
||||
mod settings_store;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user