Choose the export folder by walking the server, not by typing it

The destination for a Nextcloud export was a text field. Nobody recalls the
exact spelling of a path three levels down, and getting it wrong does not
fail — `create_dir` makes whatever was typed, so a misremembered folder
becomes a new one at the root and the exports are somewhere nobody looks.

So it is picked the way the library root is picked, using the same
`FolderBrowser` model the launch screen drives: up, into, and "use this
folder", confirming the folder currently *shown* rather than one selected in
the list. Same rule in both places, so the phrase means one thing.

The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a
near-twin of the launch screen's, because that one reaches into the
`LaunchController` for its session and reports onto the launch screen's error
line, while this one is handed credentials and writes to the settings page.
Factoring them together needs a function taking both controllers or a trait
implemented twice to abstract two call sites — more machinery than the twenty
lines it saves. What matters is shared already: navigation behaves identically
because both drive the same model.

The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`,
because listing a remote folder needs credentials and the settings page holds
no session on purpose — it is reachable before a library is opened and must
not depend on one existing. With no account the picker says to sign in first,
rather than showing an empty list that reads as a server with no folders.

Details that are decisions rather than accidents: the picker opens at the
library root rather than at whatever half-typed path is in the field, which
would list nothing and look broken. The listing area is a fixed 180px, since a
folder with sixty children would otherwise push the rest of the settings page
off the bottom. "Up" is disabled at the root rather than hidden, so the row
does not jump as the user navigates. A failed listing leaves the picker open
on the folder it was showing — where the user had got to is not something to
discard over a dropped request. And the chosen folder saves immediately like
every other setting on a page that has no Save button.

The poll timer lives on the controller for the reason `LaunchController` keeps
its own there: a `slint::Timer` stops when dropped, so one local to the
function that starts it would be collected before the listing arrived.

Carries in-flight work from a parallel session — a segmentation pass in
dr-gpu, a sidecar cache, and the develop panel's continuing changes.

1020 tests pass, fmt clean. One clippy warning remains and is not mine:
`sidecar_cache::dir` is unused while that work is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:12:01 +02:00
co-authored by Claude Opus 5
parent e00c99b864
commit cb1d2be240
16 changed files with 3292 additions and 140 deletions
+178
View File
@@ -45,6 +45,23 @@ pub struct SettingsController {
/// How much disk the cache is currently using, as a label. Supplied by
/// whoever owns the catalog — this module has no connection to query.
usage_label: RefCell<String>,
/// TRACES: FR-EXP-6
/// The remote folder picker, while it is open.
///
/// The same [`FolderBrowser`](crate::launch::FolderBrowser) the launch
/// screen uses to choose a library root, reused rather than reimplemented:
/// it browses a remote tree and nothing about it is specific to what the
/// chosen folder is *for*. `None` means the picker is closed, which is
/// also the only state a device destination ever has — a path on this
/// machine is typed or chosen by the platform, not walked over WebDAV.
pub browser: RefCell<Option<crate::launch::FolderBrowser>>,
/// Polls the folder listing while one is in flight.
///
/// Held here rather than in the function that starts it: a `slint::Timer`
/// stops the moment it is dropped, so a timer local to `spawn_folder_list`
/// would be collected before the listing it is waiting on ever arrived.
/// The same place `LaunchController` keeps its own poll timer.
poll_timer: RefCell<Option<slint::Timer>>,
}
impl SettingsController {
@@ -56,6 +73,8 @@ impl SettingsController {
store,
error: RefCell::new(None),
usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
})
}
@@ -64,6 +83,21 @@ impl SettingsController {
self.settings.borrow().clone()
}
/// Adopt a folder chosen in the picker as the export destination.
///
/// Goes through `edit` like every other change, so it is saved the moment
/// it is chosen — the page has no Save button and a destination that
/// survived only until the window closed would be the one setting that
/// behaved differently from all the others.
pub fn set_destination(&self, path: String) {
self.edit(|s| s.export.destination = path);
}
/// Report a failure onto the page's error line.
pub fn set_error(&self, message: impl Into<String>) {
*self.error.borrow_mut() = Some(message.into());
}
/// Show what the cache is holding. Empty hides the line.
pub fn set_usage_label(&self, label: String) {
*self.usage_label.borrow_mut() = label;
@@ -173,6 +207,40 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
.into(),
);
// --- the remote folder picker --------------------------------------
{
let browser = controller.browser.borrow();
window.set_settings_browse_open(browser.is_some());
match browser.as_ref() {
Some(b) => {
// The root is shown as a word rather than as an empty string,
// which would read as a control that had lost its value.
window.set_settings_browse_path(
if b.path.is_empty() {
"Library root".to_string()
} else {
b.path.clone()
}
.into(),
);
window.set_settings_browse_loading(b.loading);
window.set_settings_browse_at_root(b.parent_path().is_none());
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
b.entries
.iter()
.map(|e| slint::SharedString::from(e.as_str()))
.collect::<Vec<_>>(),
)));
}
None => {
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
Vec::<slint::SharedString>::new(),
)));
window.set_settings_browse_loading(false);
}
}
}
window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into());
}
@@ -490,6 +558,112 @@ where
}
}
/// TRACES: FR-EXP-6
/// List the folders under `path`, for the export destination picker.
///
/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared
/// with it. That one reaches into the `LaunchController` for its session and
/// reports failures onto the launch screen's error line; this one is handed
/// credentials and writes to the settings page. Factoring them together would
/// mean a function taking both controllers, or a trait implemented twice to
/// abstract two call sites — more machinery than the twenty lines it saves.
///
/// The *model* is shared, which is the part that matters: both drive a
/// [`FolderBrowser`](crate::launch::FolderBrowser), so navigation behaves
/// identically in both places.
pub fn spawn_folder_list(
weak: slint::Weak<AppWindow>,
ctl: Rc<SettingsController>,
creds: dr_sync_nextcloud::AppCredentials,
user_id: String,
path: String,
) {
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::NextcloudBackend;
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("runtime".into()));
return;
};
rt.block_on(async {
match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
.iter()
.filter(|e| e.kind == dr_sync::EntryKind::Directory)
.map(|e| e.path.name().to_string())
.collect();
dirs.sort_by_key(|d| d.to_ascii_lowercase());
let _ = tx.send(Ok(dirs));
}
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
},
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
}
});
});
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(150),
move || {
let Some(w) = weak.upgrade() else { return };
match rx.try_recv() {
Ok(Ok(dirs)) => {
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.entries = dirs;
b.loading = false;
}
render(&w, &ctl_cb);
}
Ok(Err(e)) => {
// The picker stays open showing the folder it was on. A
// listing that failed is not a reason to discard where the
// user had navigated to.
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.loading = false;
}
*ctl_cb.error.borrow_mut() = Some(format!("Could not list folders: {e}"));
render(&w, &ctl_cb);
}
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.loading = false;
}
render(&w, &ctl_cb);
}
}
// The channel has delivered, so there is nothing left to poll
// for. Stopping it here rather than leaving it running is what
// keeps a page opened and closed twenty times from accumulating
// twenty timers.
if let Some(t) = ctl_cb.poll_timer.borrow().as_ref() {
t.stop();
}
},
);
*ctl.poll_timer.borrow_mut() = Some(timer);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -514,6 +688,8 @@ mod tests {
store,
error: RefCell::new(None),
usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
})
}
@@ -584,6 +760,8 @@ mod tests {
store: SettingsStore::open_at(blocker.join("settings.json")),
error: RefCell::new(None),
usage_label: RefCell::new(String::new()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
};
broken.edit(|s| s.export.quality = 50);