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