diff --git a/PKGBUILD b/PKGBUILD
index 8d177e0..be0f553 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -63,7 +63,10 @@ package() {
io.github.gnacho.nextsync
io.github.gnacho.nextsync-folder
nextsync-tray-cloud
+ nextsync-tray-cloud-alert
+ nextsync-tray-cloud-check
nextsync-tray-cloud-off
+ nextsync-tray-cloud-sync
nextsync-tray-settings
)
for icon in "${colored[@]}"; do
diff --git a/data/icons/nextsync-tray-cloud-alert.svg b/data/icons/nextsync-tray-cloud-alert.svg
new file mode 100644
index 0000000..32e4dd4
--- /dev/null
+++ b/data/icons/nextsync-tray-cloud-alert.svg
@@ -0,0 +1,15 @@
+
diff --git a/data/icons/nextsync-tray-cloud-check.svg b/data/icons/nextsync-tray-cloud-check.svg
new file mode 100644
index 0000000..af94e12
--- /dev/null
+++ b/data/icons/nextsync-tray-cloud-check.svg
@@ -0,0 +1,14 @@
+
diff --git a/data/icons/nextsync-tray-cloud-off.svg b/data/icons/nextsync-tray-cloud-off.svg
index 8f6c05b..be546af 100644
--- a/data/icons/nextsync-tray-cloud-off.svg
+++ b/data/icons/nextsync-tray-cloud-off.svg
@@ -5,7 +5,7 @@
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
- stroke-width="2"
+ stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
diff --git a/data/icons/nextsync-tray-cloud-sync.svg b/data/icons/nextsync-tray-cloud-sync.svg
new file mode 100644
index 0000000..de82f62
--- /dev/null
+++ b/data/icons/nextsync-tray-cloud-sync.svg
@@ -0,0 +1,17 @@
+
diff --git a/data/icons/nextsync-tray-cloud.svg b/data/icons/nextsync-tray-cloud.svg
index 6bf30f3..0f015bc 100644
--- a/data/icons/nextsync-tray-cloud.svg
+++ b/data/icons/nextsync-tray-cloud.svg
@@ -5,7 +5,7 @@
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
- stroke-width="2"
+ stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
diff --git a/data/icons/nextsync-tray-settings.svg b/data/icons/nextsync-tray-settings.svg
index 7bb9c64..f33166e 100644
--- a/data/icons/nextsync-tray-settings.svg
+++ b/data/icons/nextsync-tray-settings.svg
@@ -5,7 +5,7 @@
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
- stroke-width="2"
+ stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
diff --git a/po/es.po b/po/es.po
index 37431ff..b1df444 100644
--- a/po/es.po
+++ b/po/es.po
@@ -1457,9 +1457,10 @@ msgstr "¿Eliminar la cuenta de Nextcloud?"
#: src/nextsync/ui/settings.py:709
msgid ""
-"The account credential will be removed from the password keyring. Your local "
-"NextCloud folder and all files inside it will remain untouched."
-msgstr "La credencial de la cuenta se eliminará del almacén de contraseñas. Tu carpeta local de NextCloud y todos los archivos que contiene quedarán intactos."
+"No files are deleted: neither the local synchronized folders nor anything "
+"on the server. Only this app forgets the account, its credentials and its "
+"sync configuration."
+msgstr "No se elimina ningún archivo: ni las carpetas locales sincronizadas ni nada del servidor. Solo esta aplicación olvida la cuenta, sus credenciales y su configuración de sincronización."
#: src/nextsync/ui/settings.py:712 src/nextsync/ui/setup.py:133
msgid "Continue"
@@ -1473,10 +1474,12 @@ msgstr ""
#: src/nextsync/ui/settings.py:725
msgid ""
"Type “remove” to confirm. This cannot be undone and stops synchronization "
-"immediately."
+"immediately. No files are deleted, locally or on the server; the account "
+"is simply forgotten in this app."
msgstr ""
"Escribe «remove» para confirmar. Esta acción no se puede deshacer y detiene "
-"la sincronización de inmediato."
+"la sincronización de inmediato. No se elimina ningún archivo, ni en local "
+"ni en el servidor; la aplicación simplemente olvida la cuenta."
#: src/nextsync/ui/settings.py:727
msgid "Type “remove”"
@@ -1486,10 +1489,6 @@ msgstr "Escribe «remove»"
msgid "Type “remove” to confirm account removal."
msgstr "Escribe «remove» para confirmar la eliminación de la cuenta."
-#: src/nextsync/ui/setup.py:34
-msgid "Set Up NextSync"
-msgstr "Configurar NextSync"
-
#: src/nextsync/ui/setup.py:84
msgid "A lightweight desktop synchronizer for Nextcloud."
msgstr "Un sincronizador de escritorio ligero para Nextcloud."
@@ -2222,8 +2221,8 @@ msgid "Both the local folder and the remote folder contain files. Confirm that y
msgstr "Tanto la carpeta local como la remota contienen archivos. Confirma que quieres fusionar su contenido."
#: src/core/sync_safety.rs
-msgid "This folder was synchronized before. Hidden sync journal files were found: {names}. They record what the engine already transferred. Starting fresh moves them to the trash and re-downloads from the server; keeping them resumes the previous sync history."
-msgstr "Esta carpeta se sincronizó anteriormente. Se encontraron archivos ocultos de registro de sincronización: {names}. Registran lo que el motor ya transfirió. Empezar de cero los mueve a la papelera y vuelve a descargar todo del servidor; mantenerlos reanuda el historial de sincronización anterior."
+msgid "This folder was synchronized before."
+msgstr "Esta carpeta se sincronizó anteriormente."
#: src/ui/safety_review.rs
msgid "Keep Synchronization History"
diff --git a/src/core/scheduler.rs b/src/core/scheduler.rs
index 7a6d486..cc859f1 100644
--- a/src/core/scheduler.rs
+++ b/src/core/scheduler.rs
@@ -437,9 +437,13 @@ impl SchedulerInner {
return;
}
if self.keyring_locked && trigger != Trigger::Manual {
+ // Issue #85: the keyring was locked or unreachable when the last
+ // run started (common right after login while the secret service
+ // comes up). Re-attempt on the next automatic trigger instead of
+ // parking the folder in needs-attention: the lookup is cheap and
+ // this state carries no brute-force risk (that is the auth gate).
self.queue.add(trigger);
- self.state
- .set(AppState::KeyringLocked, t("Password keyring is locked"));
+ self.schedule_start();
return;
}
if let Some((_reason, message)) = self.environment_gate(trigger == Trigger::Manual) {
@@ -1140,7 +1144,7 @@ mod tests {
}
#[test]
- fn locked_keyring_defers_automatic_triggers_until_manual_unlock() {
+ fn locked_keyring_retries_on_the_next_automatic_trigger() {
let (scheduler, source, runner) = make_scheduler(None);
scheduler.request(Trigger::Startup);
run_idle(&source);
@@ -1149,16 +1153,15 @@ mod tests {
assert!(scheduler.keyring_locked());
assert_eq!(scheduler.state().snapshot().state, AppState::KeyringLocked);
+ // Issue #85: an automatic trigger retries the run instead of parking
+ // the folder in needs-attention; the keyring may have come up in the
+ // meantime and the lookup is cheap.
scheduler.request(Trigger::LocalInotify);
- scheduler.request(Trigger::RemoteInterval);
- assert_eq!(runner.0.borrow().start_calls, 1);
- assert_eq!(scheduler.queue_len(), 2);
-
- scheduler.request(Trigger::Manual);
+ assert_eq!(scheduler.queue_len(), 1);
+ run_idle(&source);
assert_eq!(runner.0.borrow().start_calls, 2);
finish(&runner, SyncOutcome::Success);
assert!(!scheduler.keyring_locked());
- assert_eq!(runner.0.borrow().start_calls, 2);
assert_eq!(scheduler.state().snapshot().state, AppState::IdleOk);
}
diff --git a/src/main.rs b/src/main.rs
index d27e7dc..c384870 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -36,6 +36,21 @@ fn main() {
.application_id(APPLICATION_ID)
.build();
+ // App-wide style: the folder rows show their title in bold (issue #78).
+ // The internal title label has no public handle, so the weight is set
+ // through CSS on every folder-source row.
+ if let Some(display) = gtk4::gdk::Display::default() {
+ let provider = gtk4::CssProvider::new();
+ provider.load_from_bytes(>k4::glib::Bytes::from(
+ "row.folder-source .title label { font-weight: 700; }".as_bytes(),
+ ));
+ gtk4::style_context_add_provider_for_display(
+ &display,
+ &provider,
+ gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
+ );
+ }
+
let window_slot: WindowSlot = Rc::new(RefCell::new(None));
let tray_slot: TraySlot = Rc::new(RefCell::new(None));
let tray_subscription: TraySubscriptionSlot = Rc::new(RefCell::new(None));
diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs
index 0964962..091dc27 100644
--- a/src/nextcloud/api.rs
+++ b/src/nextcloud/api.rs
@@ -544,7 +544,10 @@ impl NextcloudApi {
}) {
continue;
}
- let name = entry.href_path.rsplit('/').next().unwrap_or_default();
+ // WebDAV hrefs arrive percent-encoded; decode so pickers show
+ // and store the real name (issue #88). The engine re-encodes
+ // when building URLs.
+ let name = percent_decode_path(entry.href_path.rsplit('/').next().unwrap_or_default());
folders.push(format!("/{name}"));
}
folders.sort();
@@ -937,14 +940,31 @@ impl NextcloudApi {
let authorization = basic_authorization(username, password);
let headers = [("Authorization", authorization.as_str())];
let response = self.http.request("GET", &url, &headers, None)?;
+ // Nextcloud answers 404 with the generated placeholder avatar in the
+ // body when the user never uploaded one, so trust image bytes over
+ // the status code (verified against a real server).
+ if (response.status == 200 || response.status == 404)
+ && Self::has_image_magic(&response.body)
+ {
+ return Ok(Some(response.body));
+ }
match response.status {
- 200 if !response.body.is_empty() => Ok(Some(response.body)),
200 | 404 | 301..=308 => Ok(None),
401 | 403 => Err(ApiError::AuthRejected),
status => Err(ApiError::Http { status }),
}
}
+ /// Whether the payload starts with a known image signature (PNG, JPEG,
+ /// GIF or WebP). Avatar endpoints answer errors with JSON/text bodies,
+ /// which must not be painted as images.
+ fn has_image_magic(body: &[u8]) -> bool {
+ body.starts_with(&[0x89, b'P', b'N', b'G'])
+ || body.starts_with(&[0xFF, 0xD8, 0xFF])
+ || body.starts_with(b"GIF8")
+ || (body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP")
+ }
+
/// Run a Depth-1 PROPFIND and parse the multistatus response.
fn propfind(
&self,
@@ -986,6 +1006,31 @@ fn percent_encode_path(value: &str) -> String {
encoded
}
+/// Percent-decode a path segment: `%XX` escapes (and bare `+`, never used
+/// for spaces in paths) back to their bytes. WebDAV hrefs arrive encoded,
+/// so folder names must be decoded before display or storage (issue #88);
+/// the engine re-encodes when it builds URLs, keeping a single encoding
+/// end-to-end.
+fn percent_decode_path(value: &str) -> String {
+ let bytes = value.as_bytes();
+ let mut decoded = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ // Two hex digits must follow the percent sign; anything else is
+ // kept literally.
+ if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) {
+ decoded.push(byte);
+ i += 3;
+ continue;
+ }
+ }
+ decoded.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&decoded).into_owned()
+}
+
/// Base URL of the per-user WebDAV root.
fn dav_base(server: &str, username: &str) -> String {
format!(
@@ -1219,6 +1264,13 @@ mod tests {
HTTP/1.1 200 OK
+
+ /remote.php/dav/files/alice/M%C3%BAsica%20Albums/
+
+
+ HTTP/1.1 200 OK
+
+ /remote.php/dav/files/alice/report.pdf
@@ -1824,7 +1876,7 @@ mod tests {
let folders = api
.list_remote_folders("https://cloud.example.com", "alice", "secret")
.unwrap();
- assert_eq!(folders, ["/Documents", "/Photos"]);
+ assert_eq!(folders, ["/Documents", "/Música Albums", "/Photos"]);
}
#[test]
@@ -2323,10 +2375,26 @@ mod tests {
let api = NextcloudApi::new();
let folders = api.list_remote_folders(&base, "alice", "secret").unwrap();
- assert_eq!(folders, ["/Documents", "/Photos"]);
+ // Percent-encoded hrefs decode to the real names (issue #88).
+ assert_eq!(folders, ["/Documents", "/Música Albums", "/Photos"]);
handle.join().unwrap();
}
+ #[test]
+ fn percent_decode_round_trips_spaces_and_accents() {
+ // The inverse of the encoder used when building URLs: decoding what
+ // the encoder produced must return the original, so the engine's
+ // single re-encoding yields exactly one %XX per special byte. The
+ // picker works with single-segment names (the folder name).
+ for name in ["Música Albums", "a b&c", "plain", "100% seguro", "üñïçø∂é"] {
+ let encoded = percent_encode_path(name);
+ assert_eq!(percent_decode_path(&encoded), name, "name: {name}");
+ }
+ // Malformed escapes and bare percents stay literal.
+ assert_eq!(percent_decode_path("100%"), "100%");
+ assert_eq!(percent_decode_path("%zz"), "%zz");
+ }
+
/// The real transport maps a local 401 to `AuthRejected`.
#[test]
fn integration_auth_rejection_against_local_server() {
@@ -2430,8 +2498,8 @@ mod tests {
}
#[test]
- fn avatar_is_none_on_404_redirect_or_empty_body() {
- for status in [404u16, 301, 302, 200] {
+ fn avatar_is_none_on_redirect_or_empty_body() {
+ for status in [301u16, 302, 200] {
let body = if status == 200 { b"" } else { AVATAR_PNG };
let api = NextcloudApi::with_http(Box::new(FakeHttp::new(status, body)));
let avatar = api
@@ -2446,6 +2514,33 @@ mod tests {
}
}
+ #[test]
+ fn avatar_uses_image_body_even_on_404() {
+ // Nextcloud ships the generated placeholder avatar with a 404 status
+ // when the user has no custom one; a JSON error body stays None.
+ let api = NextcloudApi::with_http(Box::new(FakeHttp::new(404, AVATAR_PNG)));
+ let avatar = api
+ .fetch_avatar(
+ crate::nextcloud::driver::Provider::Nextcloud,
+ "https://cloud.example.com",
+ "alice",
+ "secret",
+ )
+ .unwrap();
+ assert_eq!(avatar.as_deref(), Some(AVATAR_PNG));
+
+ let api = NextcloudApi::with_http(Box::new(FakeHttp::new(404, b"[]")));
+ let avatar = api
+ .fetch_avatar(
+ crate::nextcloud::driver::Provider::Nextcloud,
+ "https://cloud.example.com",
+ "alice",
+ "secret",
+ )
+ .unwrap();
+ assert_eq!(avatar, None);
+ }
+
#[test]
fn avatar_maps_auth_and_http_failures() {
let api = NextcloudApi::with_http(Box::new(FakeHttp::new(401, b"")));
diff --git a/src/nextcloud/sync_engine.rs b/src/nextcloud/sync_engine.rs
index 21290d6..2d3cd60 100644
--- a/src/nextcloud/sync_engine.rs
+++ b/src/nextcloud/sync_engine.rs
@@ -60,6 +60,19 @@ pub enum CredentialLookup {
Unavailable,
}
+impl CredentialLookup {
+ /// Whether the failure is transient infrastructure trouble (issue #85):
+ /// the session bus or the secret service is not ready yet, the
+ /// collection is locked, or the agent answered something unexpected.
+ /// Retrying later is meaningful; nothing here says the password is bad.
+ pub fn is_transient(&self) -> bool {
+ matches!(
+ self,
+ CredentialLookup::Locked | CredentialLookup::Unavailable
+ )
+ }
+}
+
/// Supplies the account password to the engine.
///
/// Kept behind a trait so tests can inject deterministic lookups instead of
@@ -308,10 +321,15 @@ fn engine_thread(
let started = Instant::now();
let password = match credentials.lookup(&inputs.account) {
CredentialLookup::Found(password) => password,
- CredentialLookup::Locked => return EngineRun::Direct(SyncOutcome::KeyringLocked),
- CredentialLookup::Missing | CredentialLookup::Unavailable => {
- return EngineRun::Direct(SyncOutcome::AuthFailed);
+ // Transient secret-service trouble (bus not ready at startup, locked
+ // collection, agent hiccup) must not arm the credential gate
+ // (issue #85): report the keyring as locked so the periodic triggers
+ // retry instead of parking the account in needs-attention. Only a
+ // truly missing item keeps the auth-rejected meaning.
+ CredentialLookup::Locked | CredentialLookup::Unavailable => {
+ return EngineRun::Direct(SyncOutcome::KeyringLocked)
}
+ CredentialLookup::Missing => return EngineRun::Direct(SyncOutcome::AuthFailed),
};
// `nextcloudcmd` exits 1 with no output when the remote folder does not
// exist; create it (and its parents) first. Auth rejection surfaces as
@@ -642,6 +660,26 @@ mod tests {
assert!(events.is_empty());
}
+ #[test]
+ fn unreachable_secret_service_maps_to_keyring_locked() {
+ // Issue #85: at startup the session bus may not be ready yet; that
+ // is transient infrastructure trouble, not rejected credentials,
+ // so it must not arm the auth gate.
+ let (progress_tx, progress_rx) = async_channel::unbounded();
+ let engine = SyncEngine::new(
+ account(),
+ folder(),
+ NetworkConfig::default(),
+ None,
+ None,
+ progress_tx,
+ )
+ .with_credentials(Arc::new(FakeCredentials(CredentialLookup::Unavailable)));
+ let (outcome, events) = run_engine(engine, &progress_rx);
+ assert_eq!(outcome, SyncOutcome::KeyringLocked);
+ assert!(events.is_empty());
+ }
+
#[test]
fn missing_credential_maps_to_auth_failed() {
let (progress_tx, progress_rx) = async_channel::unbounded();
diff --git a/src/ui/conflict_resolver.rs b/src/ui/conflict_resolver.rs
index b28af82..04cc25e 100644
--- a/src/ui/conflict_resolver.rs
+++ b/src/ui/conflict_resolver.rs
@@ -165,8 +165,17 @@ impl ConflictResolverWindow {
.build();
let conflict_scroller = gtk4::ScrolledWindow::builder().vexpand(true).build();
conflict_scroller.set_child(Some(&conflict_list));
+ // Bulk actions over the whole list (issue #77); kept hidden until a
+ // scan finds conflicts.
+ let bulk_bar = gtk4::Box::builder()
+ .orientation(gtk4::Orientation::Horizontal)
+ .spacing(8)
+ .halign(gtk4::Align::End)
+ .build();
+ bulk_bar.set_visible(false);
conflicts_box.append(&summary);
conflicts_box.append(&empty_state);
+ conflicts_box.append(&bulk_bar);
conflicts_box.append(&conflict_scroller);
let conflicts_page = stack.add_named(&conflicts_box, Some("conflicts"));
conflicts_page.set_title(Some(t("Conflicts")));
@@ -195,10 +204,13 @@ impl ConflictResolverWindow {
list: conflict_list.clone(),
summary: summary.clone(),
empty_state: empty_state.clone(),
+ bulk_bar: bulk_bar.clone(),
+ parent: window.upcast_ref::().clone(),
local_root: local_root.as_ref().to_path_buf(),
matcher: matcher.clone(),
toast_overlay: toast_overlay.clone(),
};
+ wire_bulk_buttons(&bulk_bar, &target);
let target_for_refresh = target.clone();
refresh.connect_clicked(move |_| target_for_refresh.reload());
target.reload();
@@ -257,6 +269,10 @@ struct ReloadTarget {
list: gtk4::ListBox,
summary: gtk4::Label,
empty_state: libadwaita::StatusPage,
+ /// The bulk action bar, shown only while conflicts exist (issue #77).
+ bulk_bar: gtk4::Box,
+ /// The resolver window, parent of the bulk confirmation dialog.
+ parent: gtk4::Widget,
local_root: PathBuf,
matcher: ExclusionMatcher,
toast_overlay: libadwaita::ToastOverlay,
@@ -265,14 +281,7 @@ struct ReloadTarget {
impl ReloadTarget {
/// Rescan the folder and rebuild the Conflicts list.
fn reload(&self) {
- reload_conflicts(
- &self.list,
- &self.summary,
- &self.empty_state,
- &self.local_root,
- &self.matcher,
- &self.toast_overlay,
- );
+ reload_conflicts(self);
}
/// Show a toast on the window's overlay.
@@ -282,21 +291,97 @@ impl ReloadTarget {
}
}
+/// Which side every conflict should be resolved to (issue #77).
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum BulkSide {
+ Local,
+ Remote,
+}
+
+/// Wire the two bulk buttons: confirm with the real count, then resolve
+/// every conflict in one pass and reload.
+fn wire_bulk_buttons(bulk_bar: >k4::Box, target: &ReloadTarget) {
+ let mut side_buttons = [
+ (BulkSide::Local, t("Keep Local for All")),
+ (BulkSide::Remote, t("Keep Remote for All")),
+ ];
+ for (side, label) in side_buttons.iter_mut() {
+ let button = gtk4::Button::builder().label(*label).build();
+ let target_for_bulk = target.clone();
+ let side = *side;
+ button.connect_clicked(move |_| confirm_bulk_resolve(&target_for_bulk, side));
+ bulk_bar.append(&button);
+ }
+}
+
+/// Ask for confirmation with the conflict count, then resolve them all.
+fn confirm_bulk_resolve(target: &ReloadTarget, side: BulkSide) {
+ let conflicts = find_conflicts(&target.local_root, &target.matcher);
+ if conflicts.is_empty() {
+ return;
+ }
+ let count = conflicts.len();
+ let (question, action, done) = match side {
+ BulkSide::Local => (
+ t("Keep the local version of all {count} conflicted copy(ies)? The conflicted copies are deleted."),
+ t("Keep Local for All"),
+ t("Kept the local version of {count} file(s)"),
+ ),
+ BulkSide::Remote => (
+ t("Keep the server version of all {count} conflicted copy(ies)? The working files are replaced."),
+ t("Keep Remote for All"),
+ t("Kept the server version of {count} file(s)"),
+ ),
+ };
+ let dialog = libadwaita::AlertDialog::new(
+ Some(t("Resolve All Conflicts")),
+ Some(question.replacen("{count}", &count.to_string(), 1).as_str()),
+ );
+ dialog.add_response("cancel", t("Cancel"));
+ dialog.add_response("resolve", action);
+ dialog.set_response_appearance("resolve", libadwaita::ResponseAppearance::Destructive);
+ let target_for_apply = target.clone();
+ dialog.connect_response(None, move |dialog, response| {
+ if response != "resolve" {
+ return;
+ }
+ dialog.close();
+ let resolved = apply_bulk_resolve(&target_for_apply, side);
+ target_for_apply.toast(done.replacen("{count}", &resolved.to_string(), 1));
+ target_for_apply.reload();
+ });
+ dialog.present(Some(&target.parent));
+}
+
+/// Resolve every conflict to `side`; returns how many succeeded.
+fn apply_bulk_resolve(target: &ReloadTarget, side: BulkSide) -> usize {
+ let conflicts = find_conflicts(&target.local_root, &target.matcher);
+ conflicts
+ .iter()
+ .filter(|conflict| match side {
+ BulkSide::Local => keep_local(conflict),
+ BulkSide::Remote => keep_remote(conflict),
+ })
+ .count()
+}
+
/// Rebuild the Conflicts list from a fresh scan.
-fn reload_conflicts(
- list: >k4::ListBox,
- summary: >k4::Label,
- empty_state: &libadwaita::StatusPage,
- local_root: &Path,
- matcher: &ExclusionMatcher,
- toast_overlay: &libadwaita::ToastOverlay,
-) {
+fn reload_conflicts(target: &ReloadTarget) {
+ let (list, summary, empty_state, bulk_bar, local_root, matcher) = (
+ &target.list,
+ &target.summary,
+ &target.empty_state,
+ &target.bulk_bar,
+ &target.local_root,
+ &target.matcher,
+ );
while let Some(child) = list.first_child() {
child.unparent();
}
let conflicts = find_conflicts(local_root, matcher);
if conflicts.is_empty() {
empty_state.set_visible(true);
+ bulk_bar.set_visible(false);
summary.set_text(
&t("No conflicted copies found in {folder}.")
.replace("{folder}", &local_root.display().to_string()),
@@ -304,21 +389,14 @@ fn reload_conflicts(
return;
}
empty_state.set_visible(false);
+ bulk_bar.set_visible(true);
summary.set_text(
&t("{count} conflicted copy(ies) found in {folder}.")
.replace("{count}", &conflicts.len().to_string())
.replace("{folder}", &local_root.display().to_string()),
);
for conflict in conflicts {
- let target = ReloadTarget {
- list: list.clone(),
- summary: summary.clone(),
- empty_state: empty_state.clone(),
- local_root: local_root.to_path_buf(),
- matcher: matcher.clone(),
- toast_overlay: toast_overlay.clone(),
- };
- list.append(&build_conflict_row(&conflict, &target));
+ list.append(&build_conflict_row(&conflict, target));
}
}
diff --git a/src/ui/folder_status.rs b/src/ui/folder_status.rs
index 3c09b02..df038bb 100644
--- a/src/ui/folder_status.rs
+++ b/src/ui/folder_status.rs
@@ -41,17 +41,44 @@ pub fn folder_status_presentation(state: AppState) -> (&'static str, &'static st
}
}
-/// The translated subtitle segment for a remote path ("Remote: {remote}").
+/// The translated subtitle segment for a synced folder ("Synced in local
+/// {folder}", issue #78). The folder is the remote path without its leading
+/// slash: the name is what matters, the slash is noise.
pub fn remote_label(remote_path: &str) -> String {
- t("Remote: {remote}").replace("{remote}", remote_path)
+ let folder = remote_path.trim_start_matches('/');
+ t("Synced in local {folder}").replace("{folder}", folder)
}
-/// The translated suffix for a folder's local used space ("{size} local",
+/// The translated suffix for the folder's local used space ("{size} local",
/// issue #43).
pub fn local_size_label(bytes: u64) -> String {
t("{size} local").replace("{size}", &crate::nextcloud::api::format_bytes(bytes))
}
+/// One line of live progress for a folder row (issue #86): the translated
+/// action, the file being operated on and, when the engine reports one, the
+/// operation counter for the whole run.
+pub fn progress_line_text(
+ progress: &crate::nextcloud::nextcloudcmd_progress::SyncProgress,
+ file: &str,
+) -> String {
+ let action = match progress.action.as_str() {
+ "download" => t("downloading {file}"),
+ "upload" => t("uploading {file}"),
+ "delete" => t("deleting {file}"),
+ "conflict" => t("conflict on {file}"),
+ _ => t("processing {file}"),
+ }
+ .replace("{file}", file);
+ if progress.processed > 0 {
+ t("{action} · {count}")
+ .replace("{action}", &action)
+ .replace("{count}", &progress.processed.to_string())
+ } else {
+ action
+ }
+}
+
/// Recursively sum the size of the regular files below `root`, skipping
/// symlinks entirely (their targets are not part of the synchronized tree).
///
@@ -141,11 +168,15 @@ pub struct FolderStatusRow {
/// Suffix label with the folder's local used space (issue #43); hidden
/// until a size is known.
pub local_size: gtk4::Label,
+ /// Suffix label with the live per-file progress (issue #86); hidden
+ /// while no synchronization is running.
+ progress_label: gtk4::Label,
_menu_button: gtk4::MenuButton,
_actions: std::collections::HashMap,
format_last_sync: Option String>>,
remote_path: String,
_subscription: Option,
+ _progress_subscription: Option,
}
impl FolderStatusRow {
@@ -169,6 +200,9 @@ impl FolderStatusRow {
.activatable(true)
.selectable(false)
.build();
+ // The bold folder name (issue #78) comes from the app-wide style
+ // provider; the internal title label has no public handle.
+ row.add_css_class("folder-source");
let icon = gtk4::Image::builder()
.icon_name("folder-symbolic")
@@ -185,6 +219,17 @@ impl FolderStatusRow {
.build();
row.add_suffix(&local_size);
+ // Live per-file progress (issue #86): small dim text next to the
+ // spinner while a run is in flight, gone when it ends.
+ let progress_label = gtk4::Label::builder()
+ .css_classes(["dim-label", "caption"])
+ .valign(gtk4::Align::Center)
+ .ellipsize(gtk4::pango::EllipsizeMode::Middle)
+ .max_width_chars(28)
+ .visible(false)
+ .build();
+ row.add_suffix(&progress_label);
+
let spinner = gtk4::Spinner::builder().build();
spinner.set_visible(false);
row.add_suffix(&spinner);
@@ -271,11 +316,13 @@ impl FolderStatusRow {
icon,
spinner,
local_size,
+ progress_label,
_menu_button: menu_button,
_actions: actions,
format_last_sync,
remote_path: remote_path.clone(),
_subscription: None,
+ _progress_subscription: None,
};
match state {
@@ -296,6 +343,22 @@ impl FolderStatusRow {
);
});
this._subscription = Some(subscription);
+ // Live per-file progress (issue #86). Only the widgets are
+ // captured, so the closure dies with the label and the row.
+ let progress_label = this.progress_label.clone();
+ let progress_subscription =
+ controller.subscribe_progress(move |progress| match progress {
+ Some(progress) if progress.is_operation() => {
+ let file = std::path::Path::new(&progress.path)
+ .file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ .unwrap_or_else(|| progress.path.clone());
+ progress_label.set_text(&progress_line_text(progress, &file));
+ progress_label.set_visible(true);
+ }
+ _ => progress_label.set_visible(false),
+ });
+ this._progress_subscription = Some(progress_subscription);
}
None => {
let snapshot = StateSnapshot::new(AppState::Unconfigured);
@@ -414,7 +477,14 @@ mod tests {
assert_eq!(label, "Sincronizado");
let (_, label) = folder_status_presentation(AppState::Offline);
assert_eq!(label, "Sin conexión");
- assert_eq!(remote_label("/docs"), "Remoto: /docs");
+ assert_eq!(remote_label("/docs"), "Sincronizado en local docs");
+ assert_eq!(remote_label("docs"), "Sincronizado en local docs");
+ // Live progress line (issue #86): translated action plus counter.
+ let mut progress =
+ crate::nextcloud::nextcloudcmd_progress::SyncProgress::new("upload", "/tmp/a/b.txt");
+ assert_eq!(progress_line_text(&progress, "b.txt"), "subiendo b.txt");
+ progress.processed = 7;
+ assert_eq!(progress_line_text(&progress, "b.txt"), "subiendo b.txt · 7");
reset_locale();
}
@@ -496,7 +566,7 @@ mod tests {
assert_eq!(row.row.title(), "a");
assert_eq!(
row.row.subtitle().as_deref(),
- Some("Synchronized · Remote: /docs")
+ Some("Synchronized · Synced in local docs")
);
// The local-size suffix stays hidden until a size arrives and
// leaves the subtitle alone (issue #43).
@@ -506,7 +576,7 @@ mod tests {
assert!(row.local_size.is_visible());
assert_eq!(
row.row.subtitle().as_deref(),
- Some("Synchronized · Remote: /docs")
+ Some("Synchronized · Synced in local docs")
);
row.set_local_size("");
assert!(!row.local_size.is_visible());
diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs
index 524f8f9..e197879 100644
--- a/src/ui/main_window.rs
+++ b/src/ui/main_window.rs
@@ -163,18 +163,26 @@ impl AccountView {
.selection_mode(gtk4::SelectionMode::None)
.build();
- // Account summary as plain text (issues #64/#69): green state icon,
- // server name, used GB — the same line for every provider — with an
- // Add Folder button beside them. The user identity lives in the
- // sidebar; no avatar, no login name here.
+ // Account summary in two stacked lines (issue #81): state light and
+ // server host, then login user and used space only (no capacity),
+ // with the Add Folder button on the right in the theme accent.
let summary_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(10)
.margin_bottom(4)
.build();
+ let summary_lines = gtk4::Box::builder()
+ .orientation(gtk4::Orientation::Vertical)
+ .spacing(2)
+ .hexpand(true)
+ .build();
+ let line_one = gtk4::Box::builder()
+ .orientation(gtk4::Orientation::Horizontal)
+ .spacing(8)
+ .build();
let light = gtk4::Image::builder().pixel_size(14).build();
light.set_icon_name(Some(summary_light_for(runtime.state().snapshot().state)));
- summary_box.append(&light);
+ line_one.append(&light);
// The server label doubles as the anti-race guard for the
// background quota fetch (detached rows keep their text).
let name_label = gtk4::Label::builder()
@@ -182,18 +190,25 @@ impl AccountView {
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
- summary_box.append(&name_label);
+ line_one.append(&name_label);
+ // Line two: who is logged in and how much space that consumes.
let usage_label = gtk4::Label::builder()
.css_classes(["dim-label"])
.halign(gtk4::Align::Start)
+ .xalign(0.0)
+ .ellipsize(gtk4::pango::EllipsizeMode::End)
+ .label(account.login_name.as_str())
.build();
- summary_box.append(&usage_label);
- // Add Folder as a real button next to the summary (issue #66).
+ summary_lines.append(&line_one);
+ summary_lines.append(&usage_label);
+ summary_box.append(&summary_lines);
+ // Add Folder as a real button next to the summary (issue #66),
+ // accented so it reads as the obvious action (issue #81).
if let Some(on_add_folder) = &callbacks.on_add_folder {
let add_button = gtk4::Button::builder()
.label(t("Add Folder"))
.tooltip_text(t("Add a local folder to synchronize with this account"))
- .css_classes(["pill"])
+ .css_classes(["pill", "suggested-action"])
.valign(gtk4::Align::Center)
.build();
let on_add_folder = on_add_folder.clone();
@@ -226,6 +241,7 @@ impl AccountView {
});
let usage_label = usage_label.clone();
let name_label = name_label.clone();
+ let login_for_quota = account.login_name.clone();
let title_for_check = name_label.text().to_string();
glib::spawn_future_local(async move {
let Ok(Some(summary)) = handle.await else {
@@ -237,17 +253,25 @@ impl AccountView {
if name_label.text() != title_for_check {
return;
}
- let mut parts = Vec::new();
- if let Some(name) = summary.display_name.clone() {
- if !name.is_empty() {
- parts.push(name);
- }
- }
- let usage = summary.usage_label();
- if !usage.is_empty() {
- parts.push(usage);
- }
- usage_label.set_text(&parts.join(" · "));
+ // Line two of the summary (issue #81): the display name
+ // when the server reports one, else the login, plus the
+ // used space only. Capacity stays out.
+ let who = summary
+ .display_name
+ .clone()
+ .filter(|name| !name.is_empty())
+ .unwrap_or_else(|| login_for_quota.clone());
+ let used = summary
+ .used
+ .map(crate::nextcloud::api::format_bytes)
+ .map(|used| t("{used} used").replace("{used}", &used))
+ .unwrap_or_default();
+ let text = if used.is_empty() {
+ who
+ } else {
+ format!("{who} · {used}")
+ };
+ usage_label.set_text(&text);
});
}
@@ -958,6 +982,18 @@ impl MainWindow {
self.config_store.clone(),
callbacks,
);
+ // Forget the wizard when its window closes, so the next Add Account
+ // opens a fresh one with empty fields instead of re-presenting the
+ // cached window with whatever was typed before (issue #80).
+ {
+ let weak = self.self_weak.clone();
+ window.window().connect_close_request(move |_window| {
+ if let Some(main) = weak.upgrade() {
+ main.borrow_mut().setup_window = None;
+ }
+ gtk4::glib::Propagation::Proceed
+ });
+ }
window.present();
self.setup_window = Some(window);
}
@@ -1210,8 +1246,50 @@ impl MainWindow {
// Issue #50: the cached account avatar when we have one, the
// initials fallback when we do not.
let avatar = libadwaita::Avatar::new(28, Some(&account.login_name), true);
- if let Some(bytes) = crate::util::avatar_cache::read_cached_avatar(&account.id) {
- paint_avatar(&avatar, &bytes);
+ let cached_avatar = crate::util::avatar_cache::read_cached_avatar(&account.id);
+ if let Some(bytes) = &cached_avatar {
+ paint_avatar(&avatar, bytes);
+ } else {
+ // The fetch was never wired in production, so the cache
+ // stayed empty and every account fell back to initials.
+ // Fetch in the background; the repaint goes through
+ // avatar_widgets, resolved at completion so a sidebar
+ // rebuilt meanwhile is not painted through a detached row.
+ let account_for_fetch = account.clone();
+ let weak = self.self_weak.clone();
+ glib::spawn_future_local(async move {
+ let account_id = account_for_fetch.id.clone();
+ let handle = gio::spawn_blocking(move || {
+ let password =
+ crate::nextcloud::credentials::CredentialsStore::get_for_account(
+ &account_for_fetch.id,
+ &account_for_fetch.server_url,
+ &account_for_fetch.login_name,
+ )
+ .ok()
+ .flatten()?;
+ let bytes = crate::nextcloud::api::NextcloudApi::new()
+ .fetch_avatar(
+ account_for_fetch.provider,
+ &account_for_fetch.server_url,
+ &account_for_fetch.login_name,
+ &password,
+ )
+ .ok()
+ .flatten()?;
+ crate::util::avatar_cache::store_avatar(&account_for_fetch.id, &bytes)
+ .ok()?;
+ Some(bytes)
+ });
+ if let Ok(Some(bytes)) = handle.await {
+ if let Some(main) = weak.upgrade() {
+ let main = main.borrow();
+ if let Some(avatar) = main.avatar_widgets.get(&account_id) {
+ paint_avatar(avatar, &bytes);
+ }
+ }
+ }
+ });
}
box_container.append(&avatar);
let text = gtk4::Box::builder()
diff --git a/src/ui/safety_review.rs b/src/ui/safety_review.rs
index 597d51c..7dc9fb4 100644
--- a/src/ui/safety_review.rs
+++ b/src/ui/safety_review.rs
@@ -42,11 +42,9 @@ pub fn review_sections(
.to_string(),
),
FirstSyncWarning::PreviouslySynced => {
- let names = facts.journal_names.join(", ");
- sections.push(
- t("This folder was synchronized before. Hidden sync journal files were found: {names}. They record what the engine already transferred. Starting fresh moves them to the trash and re-downloads from the server; keeping them resumes the previous sync history.")
- .replacen("{names}", &names, 1),
- );
+ // One line is enough (issue #74): the Keep/Start Fresh
+ // buttons below carry the decision and its consequences.
+ sections.push(t("This folder was synchronized before.").to_string());
}
FirstSyncWarning::Oversized => {
let Some(size) = facts.remote_size else {
@@ -160,7 +158,7 @@ mod tests {
}
#[test]
- fn journal_facts_list_the_file_names() {
+ fn journal_facts_say_it_in_one_line() {
set_locale(Locale::English);
let sections = review_sections(
&facts(&[".sync_1.db", ".sync_2.db"], true, Some(true)),
@@ -168,8 +166,9 @@ mod tests {
"/tmp/nc",
);
assert_eq!(sections.len(), 1);
- assert!(sections[0].contains(".sync_1.db, .sync_2.db"));
- assert!(sections[0].contains("trash"));
+ // One short line (issue #74); the buttons carry the decision, so the
+ // journal names and the trash explanation stay out of the body.
+ assert_eq!(sections[0], "This folder was synchronized before.");
reset_locale();
}
@@ -201,7 +200,7 @@ mod tests {
review_sections(&facts(&[".sync_1.db"], false, Some(false)), None, "/tmp/nc");
assert_eq!(sections.len(), 2);
assert!(sections[0].contains("merge"));
- assert!(sections[1].contains(".sync_1.db"));
+ assert_eq!(sections[1], "This folder was synchronized before.");
reset_locale();
}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 3682afd..4f20d70 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -2073,7 +2073,7 @@ fn present_sign_in_again_dialog(
fn present_remove_account(login_name: &str, host: &SettingsHost, callbacks: &SettingsCallbacks) {
let dialog = libadwaita::AlertDialog::new(
Some(t("Remove Nextcloud Account?")),
- Some(t("The account credential will be removed from the password keyring. Your local NextCloud folder and all files inside it will remain untouched.")),
+ Some(t("No files are deleted: neither the local synchronized folders nor anything on the server. Only this app forgets the account, its credentials and its sync configuration.")),
);
dialog.add_response("cancel", t("Cancel"));
dialog.add_response("remove", t("Continue"));
@@ -2102,7 +2102,7 @@ fn present_remove_account_step_two(
) {
let dialog = libadwaita::AlertDialog::new(
Some(t(&format!("Remove {login_name}?"))),
- Some(t("Type “remove” to confirm. This cannot be undone and stops synchronization immediately.")),
+ Some(t("Type “remove” to confirm. This cannot be undone and stops synchronization immediately. No files are deleted, locally or on the server; the account is simply forgotten in this app.")),
);
let entry = gtk4::Entry::new();
entry.set_placeholder_text(Some(t("Type “remove”")));
diff --git a/src/ui/setup.rs b/src/ui/setup.rs
index 1ec0973..b369465 100644
--- a/src/ui/setup.rs
+++ b/src/ui/setup.rs
@@ -3,7 +3,7 @@
//! Port of `ui/setup.py` (v0.4.0) plus the **provider selector** introduced by
//! the Rust rewrite plan. A single [`libadwaita::ApplicationWindow`] with a
//! `gtk4::Stack` walks the user through: welcome (provider selection) → server
-//! → authentication (browser flow v2 or manual sign-in) → folders → summary →
+//! → authentication (browser flow v2 or manual sign-in) → folders →
//! first-sync confirmation → account creation.
//!
//! # Deviations from `setup.py` (motivated)
@@ -75,7 +75,7 @@ use crate::storage::config::{
};
use crate::util::i18n::t;
-const WINDOW_TITLE: &str = "Set Up NextSync";
+const WINDOW_TITLE: &str = "Add Account";
/// Callback invoked once an account has been created and persisted.
pub type SetupCompleteCallback = Rc;
@@ -198,9 +198,6 @@ struct SetupWidgets {
folder_list: gtk4::ListBox,
space_label: gtk4::Label,
folder_error: gtk4::Label,
- summary_list: gtk4::ListBox,
- summary_hint: gtk4::Label,
- start_button: gtk4::Button,
}
impl SetupWidgets {
@@ -287,17 +284,6 @@ impl SetupWidgets {
space_label.set_visible(false);
let folder_error = error_label("");
- let summary_list = gtk4::ListBox::builder()
- .css_classes(["boxed-list"])
- .selection_mode(gtk4::SelectionMode::None)
- .build();
- let summary_hint = dim_label(
- t("The chosen folders will be mirrored in both directions using the Nextcloud synchronization engine."),
- );
- let start_button = gtk4::Button::with_label(t("Start Synchronizing"));
- start_button.add_css_class("suggested-action");
- start_button.set_tooltip_text(Some(t("Finish setup and start synchronizing")));
-
Self {
provider_row,
provider_warning,
@@ -318,9 +304,6 @@ impl SetupWidgets {
folder_list,
space_label,
folder_error,
- summary_list,
- summary_hint,
- start_button,
}
}
}
@@ -356,7 +339,9 @@ impl SetupWindow {
let window = libadwaita::ApplicationWindow::builder()
.application(application)
.title(t(WINDOW_TITLE))
- .default_width(620)
+ // Wider than the old 620 so full paths and folder rows fit
+ // without ellipsizing as soon (issue #75).
+ .default_width(720)
.default_height(680)
.build();
@@ -385,7 +370,6 @@ impl SetupWindow {
build_server_page(&context_for_pages);
build_authentication_page(&context_for_pages);
build_folders_page(&context_for_pages);
- build_summary_page(&context_for_pages);
stack.set_visible_child_name("welcome");
Self { window, context }
@@ -422,15 +406,42 @@ impl SetupWindow {
fn build_welcome_page(ctx: &SetupContext) {
let (page, content) = page();
- let status = libadwaita::StatusPage::builder()
+ // AdwStatusPage embeds a GtkScrolledWindow, so a page short on vertical
+ // space grew an unwanted scrollbar around the icon/title/description
+ // (issue #80). This compact header never scrolls.
+ let header = gtk4::Box::builder()
+ .orientation(gtk4::Orientation::Vertical)
+ .spacing(12)
+ .vexpand(true)
+ .valign(gtk4::Align::Center)
+ .build();
+
+ let icon = gtk4::Image::builder()
.icon_name("io.github.gnacho.nextsync")
- .title("NextSync")
- .description(t(
+ .pixel_size(64)
+ .halign(gtk4::Align::Center)
+ .build();
+ header.append(&icon);
+
+ let title = gtk4::Label::builder()
+ .label("NextSync")
+ .css_classes(["title-2"])
+ .halign(gtk4::Align::Center)
+ .build();
+ header.append(&title);
+
+ let description = gtk4::Label::builder()
+ .label(t(
"A lightweight desktop synchronizer for Nextcloud and OpenCloud.",
))
- .vexpand(true)
+ .wrap(true)
+ .justify(gtk4::Justification::Center)
+ .max_width_chars(40)
+ .halign(gtk4::Align::Center)
.build();
- content.append(&status);
+ header.append(&description);
+
+ content.append(&header);
content.append(
&dim_label(
@@ -633,12 +644,16 @@ fn build_folders_page(ctx: &SetupContext) {
let actions = action_box();
actions.append(&back_button(&ctx.stack, "authentication"));
- let review = gtk4::Button::with_label(t("Sign In"));
+ // Issue #73: this is the last step. Signing in already happened on the
+ // previous page, so the button finishes setup; with folders it opens the
+ // first-sync review, without them it connects right away. The old
+ // summary page in between only repeated what was just picked.
+ let review = gtk4::Button::with_label(t("Finish Setup"));
review.add_css_class("suggested-action");
{
let ctx = ctx.clone();
review.connect_clicked(move |_| {
- folders_continue(&ctx);
+ start_syncing(&ctx);
});
}
actions.append(&review);
@@ -655,25 +670,6 @@ fn build_folders_page(ctx: &SetupContext) {
ctx.stack.add_named(&page, Some("folders"));
}
-fn build_summary_page(ctx: &SetupContext) {
- let (page, content) = page();
- content.append(&title_label(t("Ready to Synchronize")));
- content.append(&ctx.widgets.summary_list);
- content.append(&ctx.widgets.summary_hint);
-
- let actions = action_box();
- actions.append(&back_button(&ctx.stack, "folders"));
- let ctx_for_start = ctx.clone();
- let start_button = ctx.widgets.start_button.clone();
- start_button.connect_clicked(move |_| {
- start_syncing(&ctx_for_start);
- });
- actions.append(&start_button);
- content.append(&actions);
-
- ctx.stack.add_named(&page, Some("summary"));
-}
-
// ---------------------------------------------------------------------------
// Flow handlers
// ---------------------------------------------------------------------------
@@ -1072,8 +1068,36 @@ fn present_add_folder_dialog(
} else {
&previous_remote
});
+ // Existing remote folders as a dropdown (issue #82), with the manual
+ // entry as the source of truth for new paths.
+ let remote_list = gtk4::StringList::new(&[]);
+ let picker = gtk4::DropDown::from_strings(&[]);
+ picker.set_model(Some(&remote_list));
+ picker.set_selected(u32::MAX);
+ picker.set_tooltip_text(Some(t("Choose an existing remote folder")));
+ let entry_for_pick = remote_entry.clone();
+ picker.connect_selected_notify(move |picker| {
+ if let Some(item) = picker.selected_item() {
+ if let Ok(item) = item.downcast::() {
+ entry_for_pick.set_text(&item.string());
+ picker.set_selected(u32::MAX);
+ }
+ }
+ });
+ remote_entry.add_suffix(&picker);
entry_box.append(&remote_entry);
+ let picker_status = gtk4::Label::builder()
+ .xalign(0.0)
+ .wrap(true)
+ .css_classes(["caption"])
+ .build();
+ entry_box.append(&picker_status);
+
+ if !opencloud {
+ populate_wizard_remote_picker(ctx, &remote_list, &picker_status);
+ }
+
if let Some(message) = error {
entry_box.append(&error_label(&message));
}
@@ -1133,7 +1157,10 @@ fn append_folder_row(ctx: &SetupContext, folder: &WizardFolder) {
folder.remote_path.as_str()
};
let row = libadwaita::ActionRow::builder()
- .title(folder.local_root.as_str())
+ .title(fold_home(&folder.local_root))
+ // The folded path is for display; the absolute one stays a tooltip
+ // away (issue #75).
+ .tooltip_text(folder.local_root.as_str())
.subtitle(t("Remote: {remote}").replacen("{remote}", remote_label, 1))
.build();
let icon = gtk4::Image::builder()
@@ -1162,85 +1189,6 @@ fn append_folder_row(ctx: &SetupContext, folder: &WizardFolder) {
ctx.widgets.folder_list.append(&row);
}
-/// Rebuild the summary page from the current state and navigate to it.
-fn folders_continue(ctx: &SetupContext) {
- ctx.widgets.folder_error.set_text("");
- {
- let state = ctx.state.borrow();
- ctx.widgets.summary_list.remove_all();
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Server"),
- &state.server,
- "network-server-symbolic",
- );
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Account"),
- &state.username,
- "avatar-default-symbolic",
- );
- if state.folders.is_empty() {
- append_summary_row(
- &ctx.widgets.summary_list,
- t("No Folders"),
- t("Connected without synchronization folders. Add them later from Settings."),
- "folder-symbolic",
- );
- ctx.widgets.start_button.set_label(t("Finish Setup"));
- ctx.widgets.summary_hint.set_text(
- t("The account will be connected without synchronizing any folder. You can add folders later from Settings."),
- );
- } else {
- ctx.widgets.start_button.set_label(t("Start Synchronizing"));
- ctx.widgets.summary_hint.set_text(
- t("The chosen folders will be mirrored in both directions using the Nextcloud synchronization engine."),
- );
- for folder in &state.folders {
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Local Folder"),
- &folder.local_root,
- "folder-symbolic",
- );
- let remote = if folder.remote_path.is_empty() {
- "/"
- } else {
- folder.remote_path.as_str()
- };
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Remote Folder"),
- remote,
- "folder-remote-symbolic",
- );
- }
- }
- if state.provider == Provider::OpenCloud {
- let space = state.space_id.as_deref().unwrap_or("Not set");
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Space"),
- t(space),
- "drive-multidisk-symbolic",
- );
- }
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Local Detection"),
- t("Filesystem monitor"),
- "folder-saved-search-symbolic",
- );
- append_summary_row(
- &ctx.widgets.summary_list,
- t("Remote Detection"),
- t("Server push + every 10 minutes"),
- "network-transmit-receive-symbolic",
- );
- }
- ctx.stack.set_visible_child_name("summary");
-}
-
/// Start synchronization: without folders it finishes immediately; otherwise
/// it probes the first remote folder and asks for the first-sync confirmation.
fn start_syncing(ctx: &SetupContext) {
@@ -1788,19 +1736,68 @@ fn provider_from_combo(row: &libadwaita::ComboRow) -> Provider {
}
}
-fn append_summary_row(list: >k4::ListBox, title: &str, subtitle: &str, icon: &str) {
- let row = libadwaita::ActionRow::builder()
- .title(title)
- .subtitle(subtitle)
- .build();
- if !icon.is_empty() {
- let image = gtk4::Image::builder()
- .icon_name(icon)
- .pixel_size(16)
- .build();
- row.add_prefix(&image);
+/// Display a local path with the home directory folded to `~` (issue #75):
+/// shorter to scan, with the absolute path kept in tooltips.
+fn fold_home(path: &str) -> String {
+ let home = std::env::var_os("HOME").map(|home| home.to_string_lossy().to_string());
+ fold_home_with(home.as_deref(), path)
+}
+
+/// [`fold_home`] with an explicit home directory, so the folding rules are
+/// testable without depending on the test runner's environment.
+fn fold_home_with(home: Option<&str>, path: &str) -> String {
+ let Some(home) = home.filter(|home| home.len() > 1) else {
+ return path.to_string();
+ };
+ if path == home {
+ return "~".to_string();
+ }
+ if let Some(rest) = path.strip_prefix(&format!("{home}/")) {
+ return format!("~/{rest}");
}
- list.append(&row);
+ path.to_string()
+}
+
+/// Fill the wizard's remote-folder dropdown with the server's existing
+/// folders (issue #82). The credentials come from the wizard itself: the
+/// user signed in on the previous page but the account is not persisted
+/// yet, so there is nothing in the keyring to look up. Nextcloud only; the
+/// OpenCloud wizard keeps the manual entry until its listing lands.
+fn populate_wizard_remote_picker(
+ ctx: &SetupContext,
+ list: >k4::StringList,
+ status: >k4::Label,
+) {
+ let (server, username) = {
+ let state = ctx.state.borrow();
+ (state.server.clone(), state.username.clone())
+ };
+ let password = ctx.widgets.password_entry.text().to_string();
+ let list = list.clone();
+ let status = status.clone();
+ let handle = gio::spawn_blocking(move || {
+ if server.is_empty() || username.is_empty() || password.is_empty() {
+ return None;
+ }
+ crate::nextcloud::api::NextcloudApi::new()
+ .list_remote_folders(&server, &username, &password)
+ .ok()
+ });
+ glib::spawn_future_local(async move {
+ let Ok(folders) = handle.await else {
+ return;
+ };
+ match folders {
+ Some(folders) => {
+ for folder in folders {
+ list.append(&folder);
+ }
+ }
+ // The dialog stays usable: the manual entry is the source of
+ // truth, an empty dropdown just offers no shortcuts.
+ None => status.set_text(t("Could not list the remote folders.")),
+ }
+ });
}
/// Present a folder chooser and write the selection into the entry row.
@@ -2092,10 +2089,10 @@ mod tests {
#[test]
fn wizard_title_translates_to_spanish_and_back() {
set_locale(Locale::Spanish);
- assert_eq!(t(WINDOW_TITLE), "Configurar NextSync");
+ assert_eq!(t(WINDOW_TITLE), "Añadir cuenta");
assert_eq!(t("Start Synchronizing?"), "¿Empezar a sincronizar?");
set_locale(Locale::English);
- assert_eq!(t(WINDOW_TITLE), "Set Up NextSync");
+ assert_eq!(t(WINDOW_TITLE), "Add Account");
assert_eq!(t("Start Synchronizing?"), "Start Synchronizing?");
reset_locale();
}
@@ -2285,4 +2282,23 @@ mod tests {
reset_locale();
});
}
+
+ #[test]
+ fn fold_home_folds_only_inside_the_home_tree() {
+ let home = "/home/user";
+ assert_eq!(fold_home_with(Some(home), "/home/user"), "~");
+ assert_eq!(
+ fold_home_with(Some(home), "/home/user/Documents/Cloud/Foo"),
+ "~/Documents/Cloud/Foo"
+ );
+ // Outside the home tree nothing folds, and a home-like prefix that
+ // is not the home ("/home/username2") must not fold either.
+ assert_eq!(fold_home_with(Some(home), "/tmp/foo"), "/tmp/foo");
+ assert_eq!(
+ fold_home_with(Some(home), "/home/username2/docs"),
+ "/home/username2/docs"
+ );
+ // No home directory available: the path is returned as is.
+ assert_eq!(fold_home_with(None, "/home/user/docs"), "/home/user/docs");
+ }
}
diff --git a/src/ui/tray.rs b/src/ui/tray.rs
index d4b1d49..d9370bb 100644
--- a/src/ui/tray.rs
+++ b/src/ui/tray.rs
@@ -27,7 +27,9 @@
//! The item publishes the bare themed icon name and lets the tray host resolve
//! it, mirroring the fix #18 decision of the Python client (no rasterized
//! pixmaps). The monochrome SVGs (`nextsync-tray-cloud.svg`,
-//! `nextsync-tray-cloud-off.svg`, `nextsync-status--symbolic.svg`) are
+//! `nextsync-tray-cloud-check.svg`, `nextsync-tray-cloud-sync.svg`,
+//! `nextsync-tray-cloud-alert.svg`, `nextsync-tray-cloud-off.svg`,
+//! `nextsync-status--symbolic.svg`) are
//! installed into the hicolor symbolic theme by the packaging (Task 6.2).
use std::rc::Rc;
@@ -78,13 +80,20 @@ pub struct TrayCallbacks {
/// Icon name published on the StatusNotifier item for a state.
///
-/// Mirrors `StatusNotifier._tray_icon_name` in tray.py (v0.4.0): an
-/// unconfigured install shows the struck-out cloud so the user is not left
-/// guessing that sync is merely paused; every other state (including
-/// `Offline`) uses the plain cloud glyph.
+/// One glyph per situation (issues #76 and #87): unconfigured shows the
+/// crossed-out cloud, the all-synced aggregate (`icon_key` "ok") a cloud
+/// with a check, syncing states (`icon_key` "syncing") the cloud-sync
+/// swirl, and problem states (`icon_key` "error") cloud-alert. Everything
+/// else (paused, battery, plain offline) keeps the plain cloud.
pub fn icon_name_for(state: AppState, presentation: &TrayPresentation) -> &'static str {
if presentation.icon_key == "offline" && state == AppState::Unconfigured {
"nextsync-tray-cloud-off"
+ } else if presentation.icon_key == "ok" {
+ "nextsync-tray-cloud-check"
+ } else if presentation.icon_key == "syncing" {
+ "nextsync-tray-cloud-sync"
+ } else if presentation.icon_key == "error" {
+ "nextsync-tray-cloud-alert"
} else {
"nextsync-tray-cloud"
}
@@ -106,8 +115,8 @@ pub fn status_icon_key_to_name(icon_key: &str) -> &'static str {
}
}
-/// Number of items in the tray menu (Open, Settings, Log, Pause, Quit).
-pub const MENU_ITEM_COUNT: usize = 5;
+/// Number of items in the tray menu (Open, Log, Quit).
+pub const MENU_ITEM_COUNT: usize = 3;
/// The StatusNotifier item. Only `Send` data lives here, satisfying the
/// `ksni::Tray` bound; user actions leave through the [`TrayAction`] channel.
@@ -151,29 +160,20 @@ impl TrayItem {
/// [`TrayAction`] with `try_send` (async-channel 2.x `Sender::send` is an
/// async fn and would need an executor to make progress).
fn build_menu(&self) -> Vec