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
+129 -2
View File
@@ -28,6 +28,7 @@ mod net_runtime;
mod presets;
mod settings_store;
mod settings_ui;
mod sidecar_cache;
mod trash;
use std::cell::RefCell;
@@ -768,6 +769,127 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
}
// --- the export folder picker ----------------------------------------
//
// Wired here rather than inside `settings_ui::wire` because listing a
// remote folder needs credentials, and the settings page deliberately
// holds no session — it is reachable before a library is opened and must
// not depend on one existing.
{
let weak = window.as_weak();
let ctl = settings.clone();
let library = library.clone();
// Every entry point needs the same three things, so they are fetched
// once here rather than at four call sites.
let start = move |ctl: &Rc<settings_ui::SettingsController>,
weak: &slint::Weak<AppWindow>,
library: &Rc<library_ui::LibraryController>,
path: String| {
match library.session() {
Some((creds, session)) => {
settings_ui::spawn_folder_list(
weak.clone(),
ctl.clone(),
creds,
session.user_id.clone(),
path,
);
}
None => {
// No account, so nothing to browse. Said plainly rather
// than left as an empty list, which would read as a server
// with no folders on it.
ctl.set_error("Sign in to a library before choosing a folder on it.");
ctl.browser.replace(None);
}
}
};
{
let (weak, ctl, library, start) =
(weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_open_picker(move || {
let Some(w) = weak.upgrade() else { return };
// Opens on the library root rather than on whatever the
// destination field happens to contain: a half-typed path
// would list nothing and look like a broken picker.
ctl.browser.replace(Some(launch::FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
}));
start(&ctl, &weak, &library, String::new());
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) =
(weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_into(move |name| {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let path = b.child_path(&name);
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl, library, start) =
(weak.clone(), ctl.clone(), library.clone(), start);
window.on_settings_browse_up(move || {
let Some(w) = weak.upgrade() else { return };
let path = {
let mut browser = ctl.browser.borrow_mut();
let Some(b) = browser.as_mut() else { return };
let Some(path) = b.parent_path() else { return };
b.path = path.clone();
b.entries.clear();
b.loading = true;
path
};
start(&ctl, &weak, &library, path);
settings_ui::render(&w, &ctl);
});
}
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_confirm(move || {
let Some(w) = weak.upgrade() else { return };
// The folder being *shown* is the one chosen, matching the
// library picker — so "use this one" means the same thing in
// both places rather than depending on a selection the list
// does not have.
let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone());
if let Some(path) = chosen {
ctl.set_destination(path);
}
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
refresh_export_label(&w, &ctl);
});
}
{
let (weak, ctl) = (weak.clone(), ctl.clone());
window.on_settings_browse_cancel(move || {
let Some(w) = weak.upgrade() else { return };
ctl.browser.replace(None);
settings_ui::render(&w, &ctl);
});
}
}
let lib = library.clone();
let ctl = settings.clone();
let weak = window.as_weak();
@@ -1134,8 +1256,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// it to — and starting it here means the edit is ready when the
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx =
library::spawn_sidecar_fetch(creds.clone(), user_id.clone(), path.clone());
let sidecar_rx = library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
path.clone(),
library.sidecar_cache_dir().unwrap_or_default(),
library.is_offline(),
);
// TRACES: FR-NC-6a
// The cache is consulted first, so a second open of the same