Make "Choose folder" an actual folder picker

It previously fetched the folder list and threw it away into a status
line — a button that looked like it worked and did not. Now it opens a
browsable picker: click a folder to descend, ".." to go back, "Use this
folder" to select, "Cancel" to leave the root unchanged.

Descends one level per click because that is what the backend supports:
Depth: infinity is frequently disabled server-side and prohibitively
expensive where it is not (ARCH §8.4).

The chosen root persists immediately on confirm, so it survives a crash
before the library is opened. Confirming at the account root is allowed —
a user may legitimately keep everything at the top level — and cancelling
leaves any previous selection untouched, which a test asserts.

Verified against nextcloud.tourolle.paris at both depths: 30 folders at
the root, 21 year-folders inside PhotosRaw.

19 launch tests, 38 in dr-ui.
This commit is contained in:
2026-08-09 15:50:40 +02:00
parent f630a3ff81
commit 2ca1716a29
10 changed files with 888 additions and 65 deletions
+221
View File
@@ -43,6 +43,71 @@ pub struct LaunchModel {
pub can_remember: bool,
/// Which formats to scan for, in `Format::ALL` order.
pub formats: Vec<(Format, bool)>,
/// Folder picker state, `None` when it is closed.
pub browser: Option<FolderBrowser>,
}
/// The folder picker: where it is, and what is there.
///
/// Descends one level at a time because that is what the backend supports —
/// `Depth: infinity` is frequently disabled server-side and prohibitively
/// expensive where it is not (ARCH §8.4).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FolderBrowser {
/// Path being listed. Empty is the account root.
pub path: String,
/// Child folder names at `path`, not full paths.
pub entries: Vec<String>,
/// True while a listing is in flight.
pub loading: bool,
}
impl FolderBrowser {
/// Breadcrumb segments, root first.
pub fn breadcrumbs(&self) -> Vec<String> {
let mut out = vec!["/".to_string()];
out.extend(
self.path
.split('/')
.filter(|s| !s.is_empty())
.map(str::to_string),
);
out
}
/// The path after descending into `name`.
pub fn child_path(&self, name: &str) -> String {
if self.path.is_empty() {
name.to_string()
} else {
format!("{}/{}", self.path, name)
}
}
/// The parent path, or `None` at the root.
pub fn parent_path(&self) -> Option<String> {
if self.path.is_empty() {
None
} else {
Some(match self.path.rsplit_once('/') {
Some((head, _)) => head.to_string(),
None => String::new(),
})
}
}
/// Truncate to the breadcrumb at `index` (0 is the root).
pub fn path_at_crumb(&self, index: usize) -> String {
if index == 0 {
return String::new();
}
self.path
.split('/')
.filter(|s| !s.is_empty())
.take(index)
.collect::<Vec<_>>()
.join("/")
}
}
impl Default for LaunchModel {
@@ -54,6 +119,7 @@ impl Default for LaunchModel {
status: None,
can_remember: true,
formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(),
browser: None,
}
}
}
@@ -193,6 +259,63 @@ impl LaunchModel {
};
}
// --- folder picker --------------------------------------------------
/// Open the picker at the account root.
pub fn open_browser(&mut self) {
self.error = None;
self.browser = Some(FolderBrowser {
path: String::new(),
entries: Vec::new(),
loading: true,
});
}
pub fn close_browser(&mut self) {
self.browser = None;
}
/// Begin listing `path`.
pub fn browse_to(&mut self, path: impl Into<String>) {
let path = path.into();
match &mut self.browser {
Some(b) => {
b.path = path;
b.entries.clear();
b.loading = true;
}
None => {
self.browser = Some(FolderBrowser {
path,
entries: Vec::new(),
loading: true,
})
}
}
}
/// Record a completed listing.
pub fn browser_loaded(&mut self, entries: Vec<String>) {
if let Some(b) = &mut self.browser {
b.entries = entries;
b.loading = false;
}
}
/// Adopt the picker's current path as the library root.
///
/// Returns the session to persist, or `None` when signed out.
pub fn choose_current_folder(&mut self) -> Option<Session> {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
self.browser = None;
self.state = LaunchState::SignedIn {
session: session.clone(),
};
Some(session)
}
pub fn busy(&mut self, message: impl Into<String>) {
self.error = None;
let session = self.session().cloned().map(Box::new);
@@ -377,6 +500,104 @@ mod tests {
assert!(!m.is_signed_in());
}
#[test]
fn browsing_descends_and_ascends() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
let b = m.browser.as_ref().unwrap();
assert_eq!(b.path, "", "opens at the account root");
assert!(b.loading);
m.browser_loaded(vec!["Photos".into(), "Archive".into()]);
assert!(!m.browser.as_ref().unwrap().loading);
let child = m.browser.as_ref().unwrap().child_path("Photos");
assert_eq!(child, "Photos");
m.browse_to(child);
m.browser_loaded(vec!["2026".into()]);
let deeper = m.browser.as_ref().unwrap().child_path("2026");
assert_eq!(deeper, "Photos/2026");
m.browse_to(deeper);
assert_eq!(
m.browser.as_ref().unwrap().parent_path(),
Some("Photos".into())
);
}
#[test]
fn the_root_has_no_parent() {
let b = FolderBrowser::default();
assert_eq!(b.parent_path(), None, "no way up from the account root");
}
#[test]
fn ascending_from_a_top_level_folder_reaches_the_root() {
let b = FolderBrowser {
path: "Photos".into(),
..Default::default()
};
assert_eq!(b.parent_path(), Some(String::new()));
}
#[test]
fn confirming_sets_the_library_root() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
m.browse_to("Photos/2026");
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "Photos/2026");
assert_eq!(m.library_root(), "Photos/2026");
assert!(m.browser.is_none(), "picker closes on confirm");
assert!(m.can_open_library());
}
#[test]
fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
m.open_browser();
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "");
}
#[test]
fn cancelling_leaves_the_root_unchanged() {
let mut m = LaunchModel::default();
m.signed_in(session_with_root("Original"));
m.open_browser();
m.browse_to("Somewhere/Else");
m.close_browser();
assert!(m.browser.is_none());
assert_eq!(m.library_root(), "Original", "cancel must not select");
}
#[test]
fn confirming_while_signed_out_does_nothing() {
let mut m = LaunchModel::default();
m.open_browser();
assert!(m.choose_current_folder().is_none());
}
#[test]
fn breadcrumbs_start_at_the_root() {
let b = FolderBrowser {
path: "Photos/2026/Trip".into(),
..Default::default()
};
assert_eq!(b.breadcrumbs(), vec!["/", "Photos", "2026", "Trip"]);
assert_eq!(b.path_at_crumb(0), "");
assert_eq!(b.path_at_crumb(2), "Photos/2026");
}
#[test]
fn account_label_is_empty_when_signed_out() {
assert_eq!(LaunchModel::default().account_label(), "");