Let the top level be chosen as the library root

Confirming "/" in the folder picker set an empty root, which the launch
model read as no root at all: "Open library" stayed disabled after the
question had plainly been answered, and a folder library — whose folder
is the whole library — could never be opened without first descending
into a subfolder of it. The empty string was carrying two meanings.

Record the choice as its own fact on the account (`root_chosen`, defaulted
so existing configuration loads unchanged), treat a folder endpoint as
chosen by definition, and let the launch screen say so: a folder is shown
as a LIBRARY rather than an ACCOUNT, the second question becomes an
optional "scan only a subfolder", and the library header names the folder
instead of calling it "· whole account".
This commit is contained in:
2026-09-20 00:21:13 +02:00
parent e750bcdb8c
commit 0fa9003e54
8 changed files with 218 additions and 85 deletions
+79 -10
View File
@@ -218,6 +218,43 @@ impl LaunchModel {
self.session().map(|s| s.root.clone()).unwrap_or_default()
}
/// Whether a root has been settled on, including the top level itself.
///
/// Three ways to have one: a subfolder is named; the picker confirmed
/// the top level, which `Account::root_chosen` records because the
/// empty string cannot; or the endpoint is a folder, whose top level is
/// the library by definition.
pub fn root_is_chosen(&self) -> bool {
self.session()
.is_some_and(|s| !s.root.is_empty() || s.root_chosen)
|| self.endpoint_is_library()
}
/// What the signed-in section shows for the root: empty when nothing is
/// chosen, `/` for the top level, else the path.
pub fn library_root_label(&self) -> String {
let root = self.library_root();
if root.is_empty() && self.root_is_chosen() {
"/".to_string()
} else {
root
}
}
/// Whether the endpoint itself is the library.
///
/// A folder account has no sub-root to choose: the directory the user
/// typed is the whole library, and asking them to pick a "library
/// folder" inside it a second time reads as though the first answer was
/// not taken. A cloud account is the opposite — its endpoint is an
/// entire server-side tree, and a root inside it is required (see
/// [`can_open_library`](Self::can_open_library)). Keyed on the absence
/// of a login, the same fact [`prefill`] keys on, rather than on the
/// connector's id.
pub fn endpoint_is_library(&self) -> bool {
self.session().is_some_and(|s| s.login.is_empty())
}
/// The login URL while approval is pending.
pub fn login_url(&self) -> String {
match &self.state {
@@ -228,11 +265,13 @@ impl LaunchModel {
/// Whether "Open library" should be clickable.
///
/// Requires a signed-in account *and* a chosen folder: opening without one
/// would scan the whole account, which on a real library is thousands of
/// directories the user did not ask for.
/// A cloud account requires a signed-in session *and* a chosen folder:
/// opening without one would scan the whole account, which on a real
/// library is thousands of directories the user did not ask for. Chosen
/// is the operative word, not non-empty — the top level is a legitimate
/// choice, and a folder library is the whole folder by definition.
pub fn can_open_library(&self) -> bool {
self.is_signed_in() && !self.library_root().is_empty()
self.is_signed_in() && self.root_is_chosen()
}
/// What the app should do on startup.
@@ -386,6 +425,9 @@ impl LaunchModel {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
// Even when `path` is the top level: that is a choice, and the
// empty string alone would read as none having been made.
session.root_chosen = true;
self.browser = None;
self.state = LaunchState::SignedIn {
session: session.clone(),
@@ -547,17 +589,32 @@ mod tests {
// Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026"));
assert!(m.is_signed_in());
assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026");
}
#[test]
fn a_folder_is_the_whole_library_without_choosing_a_root() {
// The directory typed on the launch screen is the answer to "which
// folder"; a second question with the same name is what confused
// the first person to open one. A server account still needs a
// root, because its endpoint is the entire tree.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.endpoint_is_library());
assert!(m.can_open_library(), "the folder itself is the library");
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
m.signed_in(session_with_root(""));
assert!(!m.endpoint_is_library());
assert!(!m.can_open_library(), "a server account needs a root");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn format_toggles_apply_and_out_of_range_is_ignored() {
let mut m = LaunchModel::default();
@@ -665,13 +722,25 @@ mod tests {
#[test]
fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level.
// Legitimate: a user may keep everything at the top level — and it
// must then open. It used to be recorded as an empty root, which
// is indistinguishable from no root, so "Open library" stayed
// disabled after the picker had plainly been answered.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
assert!(!m.can_open_library(), "nothing chosen yet");
assert_eq!(m.library_root_label(), "");
m.open_browser();
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "");
assert!(
session.root_chosen,
"the choice survives to the config file"
);
assert!(m.can_open_library());
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
}
#[test]
+2 -1
View File
@@ -44,7 +44,8 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_signed_in(m.is_signed_in());
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_root(m.library_root_label().into());
window.set_launch_endpoint_is_library(m.endpoint_is_library());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy());
+40 -8
View File
@@ -984,14 +984,7 @@ pub fn open(
window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(
if conn.account.root.is_empty() {
format!("{} · whole account", conn.account.user_id)
} else {
format!("{}/{}", conn.account.user_id, conn.account.root)
}
.into(),
);
window.set_library_root_label(library_root_label(&conn.account).into());
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
@@ -7158,10 +7151,49 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
}
}
/// The line in the library header that says what is being catalogued.
///
/// For an account with a user, the user and the chosen subtree, or the
/// whole account when none was chosen. For a folder there is no user and
/// the endpoint is the library, so the folder's own name: `library`, or
/// `library/2026` when narrowed — never " · whole account", which is a
/// sentence about a server said of a directory.
fn library_root_label(account: &Account) -> String {
if account.login.is_empty() {
let folder = std::path::Path::new(&account.endpoint)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| account.endpoint.clone());
if account.root.is_empty() {
folder
} else {
format!("{folder}/{}", account.root)
}
} else if account.root.is_empty() {
format!("{} · whole account", account.user_id)
} else {
format!("{}/{}", account.user_id, account.root)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_header_names_a_folder_library_by_its_folder() {
let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");
assert_eq!(library_root_label(&folder), "library");
folder.root = "2026".into();
assert_eq!(library_root_label(&folder), "library/2026");
let mut cloud =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
assert_eq!(library_root_label(&cloud), "duncan · whole account");
cloud.root = "PhotosRaw".into();
assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw");
}
#[test]
fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance
+2
View File
@@ -192,6 +192,7 @@ export component AppWindow inherits Window {
in property <bool> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <bool> launch-endpoint-is-library: false;
in property <string> launch-server: "";
in property <string> launch-folder: "";
in property <bool> launch-busy: false;
@@ -1546,6 +1547,7 @@ in property <bool> panel-visible: true;
signed-in: root.launch-signed-in;
account: root.launch-account;
library-root: root.launch-root;
endpoint-is-library: root.launch-endpoint-is-library;
server-url: root.launch-server;
folder-path: root.launch-folder;
busy: root.launch-busy;
+20 -5
View File
@@ -77,6 +77,10 @@ export component LaunchScreen inherits Rectangle {
in property <bool> signed-in: false;
in property <string> account: "";
in property <string> library-root: "";
/// The endpoint is the whole library — a folder — so there is no root
/// to choose inside it, only, optionally, a subfolder to narrow to. A
/// server account's endpoint is the entire tree and a root is required.
in property <bool> endpoint-is-library: false;
in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other.
@@ -349,7 +353,9 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN ONLY A SUBFOLDER") : @tr("CHOOSE LIBRARY FOLDER");
}
// Current location, so it is always clear what
// "Use this folder" would select.
@@ -428,22 +434,31 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("ACCOUNT"); }
// A folder *is* the library, and saying "account" of a
// directory, then asking for a "library folder" a second
// time, reads as though the path just typed was not
// taken. A server account genuinely has both: who it is,
// and which subtree of it to catalogue.
PanelHeading { text: root.endpoint-is-library ? @tr("LIBRARY") : @tr("ACCOUNT"); }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: @tr("LIBRARY FOLDER"); }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN") : @tr("LIBRARY FOLDER");
}
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
text: root.library-root == "" ? @tr("(not chosen)")
: root.library-root == "/" && root.endpoint-is-library ? @tr("The whole folder")
: root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: @tr("Choose…");
text: root.endpoint-is-library ? @tr("Subfolder…") : @tr("Choose…");
width: 110px;
clicked => { root.choose-folder(); }
}