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);
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user