From e518acae49eeebf4e08a2cbad96284e5bb08b60b Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 12:15:53 +0200 Subject: [PATCH 01/18] fix(ui): fresh add-account window, Add Account title, scroll-free welcome The wizard window was cached forever, so reopening Add Account re-presented the old window with the previously typed values, and the title said Set Up NextSync. Now the cache is dropped when the window closes and each opening builds a fresh wizard titled Add Account. The welcome page no longer uses AdwStatusPage, whose internal GtkScrolledWindow showed an unwanted scrollbar; a compact header replaces it. Closes #80 --- po/es.po | 4 ---- src/ui/main_window.rs | 12 +++++++++++ src/ui/setup.rs | 43 ++++++++++++++++++++++++++++++------- src/util/translations/es.rs | 1 - 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/po/es.po b/po/es.po index 37431ff..8f6dbc6 100644 --- a/po/es.po +++ b/po/es.po @@ -1486,10 +1486,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." diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 524f8f9..152ff9d 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -958,6 +958,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); } diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 1ec0973..c30b127 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -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; @@ -422,15 +422,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( @@ -2092,10 +2119,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(); } diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 528baa3..71710d2 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -331,7 +331,6 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Server Trash", "Papelera del servidor"), ("Server push + every 10 minutes", "Push del servidor + cada 10 minutos"), ("Server, proxy and synchronization options for this account", "Opciones de servidor, proxy y sincronización para esta cuenta"), - ("Set Up NextSync", "Configurar NextSync"), ("Settings", "Configuración"), ("Settings now opens independently from the tray without opening the main window.", "La Configuración ahora se abre de forma independiente desde la bandeja, sin abrir la ventana principal."), ("Show desktop notifications", "Mostrar notificaciones de escritorio"), From 04490ebfe32432b8d89654391cc87b621ef2c904 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:08:48 +0200 Subject: [PATCH 02/18] fix(api,ui): wire the avatar fetch and trust image bytes on 404 The avatar fetch existed but nothing called it outside tests, so the cache stayed empty and the sidebar always fell back to initials. The sidebar now fetches missing avatars in the background, stores them and repaints through the current avatar widget. Nextcloud answers 404 with the generated placeholder avatar in the body when the user never uploaded one, so image bodies are accepted on 200 and 404 and error JSON/text bodies are ignored. Closes #50 --- src/nextcloud/api.rs | 50 ++++++++++++++++++++++++++++++++++++++++--- src/ui/main_window.rs | 46 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs index 0964962..a521142 100644 --- a/src/nextcloud/api.rs +++ b/src/nextcloud/api.rs @@ -937,14 +937,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, @@ -2430,8 +2447,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 +2463,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/ui/main_window.rs b/src/ui/main_window.rs index 152ff9d..a20c2d7 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -1222,8 +1222,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() From a5f03d2e04fd37fae1aabe90867d6535660dfda6 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:14:09 +0200 Subject: [PATCH 03/18] feat(tray): cloud-check glyph when everything is synced The plain cloud glyph read as "app running", not as "everything synced". The all-synced aggregate (IdleOk, icon_key "ok") now shows a Lucide cloud-check SVG shipped next to the other tray glyphs; paused, offline and error states keep the plain cloud and the unconfigured install keeps the struck-out cloud. Closes #76 --- PKGBUILD | 1 + data/icons/nextsync-tray-cloud-check.svg | 14 ++++++++++++++ src/ui/tray.rs | 24 ++++++++++++++++++------ 3 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 data/icons/nextsync-tray-cloud-check.svg diff --git a/PKGBUILD b/PKGBUILD index 8d177e0..e646553 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -63,6 +63,7 @@ package() { io.github.gnacho.nextsync io.github.gnacho.nextsync-folder nextsync-tray-cloud + nextsync-tray-cloud-check nextsync-tray-cloud-off nextsync-tray-settings ) diff --git a/data/icons/nextsync-tray-cloud-check.svg b/data/icons/nextsync-tray-cloud-check.svg new file mode 100644 index 0000000..adbe126 --- /dev/null +++ b/data/icons/nextsync-tray-cloud-check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/ui/tray.rs b/src/ui/tray.rs index d4b1d49..4afc2c8 100644 --- a/src/ui/tray.rs +++ b/src/ui/tray.rs @@ -27,7 +27,8 @@ //! 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-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 +79,17 @@ 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. +/// Mirrors `StatusNotifier._tray_icon_name` in tray.py (v0.4.0) with one +/// addition: an unconfigured install shows the struck-out cloud so the user +/// is not left guessing that sync is merely paused, and the all-synced +/// aggregate (`icon_key` "ok", IdleOk) shows a cloud with a check so healthy +/// is visible at a glance; every other state (including `Offline`) uses the +/// plain cloud glyph. 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 { "nextsync-tray-cloud" } @@ -476,8 +481,15 @@ mod tests { let (offline, _rx) = item_with(AppState::Offline); assert_eq!(offline.icon_name(), "nextsync-tray-cloud"); + // Everything synced and OK gets the cloud-check glyph (issue #76). let (idle, _rx) = item_with(AppState::IdleOk); - assert_eq!(idle.icon_name(), "nextsync-tray-cloud"); + assert_eq!(idle.icon_name(), "nextsync-tray-cloud-check"); + + // Paused and error states keep the plain cloud. + let (paused, _rx) = item_with(AppState::PausedUser); + assert_eq!(paused.icon_name(), "nextsync-tray-cloud"); + let (error, _rx) = item_with(AppState::Error); + assert_eq!(error.icon_name(), "nextsync-tray-cloud"); } #[test] From ed07138a036345d9f103f3eaa68e66cd85b1b8d1 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:25:08 +0200 Subject: [PATCH 04/18] fix(ui): drop the wizard summary page and finish setup from folders The folders step ended with a Sign In button even though sign-in had already happened, and the summary page it led to only repeated what was just picked before the real first-sync review. The folders step now finishes setup directly: with folders it opens the first-sync review, without them it connects right away. Closes #73 --- src/ui/setup.rs | 140 +++--------------------------------------------- 1 file changed, 7 insertions(+), 133 deletions(-) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index c30b127..3e20604 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) @@ -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, } } } @@ -385,7 +368,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 } @@ -660,12 +642,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); @@ -682,25 +668,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 // --------------------------------------------------------------------------- @@ -1189,84 +1156,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. @@ -1815,21 +1704,6 @@ 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); - } - list.append(&row); -} - /// Present a folder chooser and write the selection into the entry row. fn choose_local_folder(entry: libadwaita::EntryRow) { let dialog = gtk4::FileDialog::builder() From a62858d5b498900171742ec3fac5475ee8e17d1f Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:26:28 +0200 Subject: [PATCH 05/18] fix(ui): one-line warning for previously synced folders The paragraph explaining what the hidden journals record and what each choice does repeated what the Keep Synchronization History and Start Fresh buttons already say. The section is now a single line; merge and oversized findings keep their longer wording. Closes #74 --- po/es.po | 4 ++-- src/ui/safety_review.rs | 17 ++++++++--------- src/util/translations/es.rs | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/po/es.po b/po/es.po index 8f6dbc6..4457bb3 100644 --- a/po/es.po +++ b/po/es.po @@ -2218,8 +2218,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/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/util/translations/es.rs b/src/util/translations/es.rs index 71710d2..ea16b31 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -401,7 +401,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("The version information could not be obtained. Check your connection and try again later.", "No se pudo obtener la información de versión. Compruebe su conexión e inténtelo de nuevo más tarde."), ("Third-party projects and licenses", "Proyectos de terceros y licencias"), ("This folder has no synchronization journal yet, so every local file counts as new. Remote changes are not included.", "Esta carpeta aún no tiene registro de sincronización, así que cada archivo local cuenta como nuevo. Los cambios remotos no se incluyen."), - ("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.", "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."), + ("This folder was synchronized before.", "Esta carpeta se sincronizó anteriormente."), ("This local folder is already added.", "Esta carpeta local ya está añadida."), ("This update is mandatory. NextSync will not start synchronization or allow use of the current version.", "Esta actualización es obligatoria. NextSync no iniciará la sincronización ni permitirá usar la versión actual."), ("This update is optional. NextSync can continue running while you decide when to install it.", "Esta actualización es opcional. NextSync puede seguir funcionando mientras decide cuándo instalarla."), From d97b360735340777d228718b7e10eb59594ae361 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:27:56 +0200 Subject: [PATCH 06/18] fix(ui): fold the home directory in wizard paths and widen the window Absolute paths in the folder rows were long and got cut off in the 620 px wizard. Rows now show ~/ prefixed paths with the absolute path in a tooltip, and the window defaults to 720 px. Closes #75 --- src/ui/setup.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 3e20604..9b815c6 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -339,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(); @@ -1127,7 +1129,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(Some(folder.local_root.as_str())) .subtitle(t("Remote: {remote}").replacen("{remote}", remote_label, 1)) .build(); let icon = gtk4::Image::builder() @@ -1156,7 +1161,6 @@ fn append_folder_row(ctx: &SetupContext, folder: &WizardFolder) { ctx.widgets.folder_list.append(&row); } - /// 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) { @@ -1704,6 +1708,28 @@ fn provider_from_combo(row: &libadwaita::ComboRow) -> Provider { } } +/// 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}"); + } + path.to_string() +} + /// Present a folder chooser and write the selection into the entry row. fn choose_local_folder(entry: libadwaita::EntryRow) { let dialog = gtk4::FileDialog::builder() @@ -2186,4 +2212,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"); + } } From 98c78ef509a8ca14dcd32c16e55a8be2a82de7d7 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:28:34 +0200 Subject: [PATCH 07/18] fix(ui): pass the row tooltip as a plain string for the builder --- src/ui/setup.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 9b815c6..3445a98 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -1132,7 +1132,7 @@ fn append_folder_row(ctx: &SetupContext, folder: &WizardFolder) { .title(fold_home(&folder.local_root)) // The folded path is for display; the absolute one stays a tooltip // away (issue #75). - .tooltip_text(Some(folder.local_root.as_str())) + .tooltip_text(folder.local_root.as_str()) .subtitle(t("Remote: {remote}").replacen("{remote}", remote_label, 1)) .build(); let icon = gtk4::Image::builder() From f3e7b68d32ac0213781ca0eb15679ae324a51b5a Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:35:37 +0200 Subject: [PATCH 08/18] feat(ui): resolve all conflicts with one bulk action Clearing a batch of conflicted copies meant the same per-row click repeated for every file. The conflicts page now shows two bulk buttons when a scan finds conflicts; each asks for confirmation with the real count, applies keep-local or keep-remote across the list and reloads. Per-row buttons stay for mixed decisions. Closes #77 --- src/ui/conflict_resolver.rs | 128 +++++++++++++++++++++++++++++------- src/util/translations/es.rs | 7 ++ 2 files changed, 110 insertions(+), 25 deletions(-) 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/util/translations/es.rs b/src/util/translations/es.rs index ea16b31..bbfafac 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -170,11 +170,17 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Items that could not be restored stay in the server trash.", "Los elementos que no se pudieron restaurar permanecen en la papelera del servidor."), ("Keep Folder", "Conservar carpeta"), ("Keep Local", "Conservar la local"), + ("Keep Local for All", "Conservar la local en todo"), ("Keep Remote", "Conservar la remota"), + ("Keep Remote for All", "Conservar la remota en todo"), ("Keep Synchronization History", "Mantener el historial de sincronización"), ("Keep daily logs (days)", "Conservar registros diarios (días)"), + ("Keep the local version of all {count} conflicted copy(ies)? The conflicted copies are deleted.", "¿Conservar la versión local de las {count} copias en conflicto? Las copias en conflicto se eliminan."), + ("Keep the server version of all {count} conflicted copy(ies)? The working files are replaced.", "¿Conservar la versión del servidor de las {count} copias en conflicto? Los archivos de trabajo se reemplazan."), ("Kept local version of {name}", "Se conservó la versión local de {name}"), ("Kept remote version of {name}", "Se conservó la versión remota de {name}"), + ("Kept the local version of {count} file(s)", "Versión local conservada en {count} archivo(s)"), + ("Kept the server version of {count} file(s)", "Versión del servidor conservada en {count} archivo(s)"), ("Languages", "Idiomas"), ("Light", "Claro"), ("Light style", "Estilo claro"), @@ -307,6 +313,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Report a problem", "Informar de un problema"), ("Required Update", "Actualización obligatoria"), ("Reset the exclusion patterns to the recommended defaults", "Restablece los patrones de exclusión a los valores recomendados"), + ("Resolve All Conflicts", "Resolver todos los conflictos"), ("Restore All", "Restaurar todo"), ("Restore Defaults", "Restaurar valores predeterminados"), ("Restore from server trash…", "Restaurar de la papelera del servidor…"), From 3028b8ea56085f44f67d71e7e1c5d5503d9d0523 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:42:08 +0200 Subject: [PATCH 09/18] feat(ui): bold folder names and synced-in-local subtitle Folder rows carried their title in the regular weight and a subtitle led by Remote: /path. The title label is now styled bold through an app-wide CSS rule on the new folder-source class, and the subtitle reads Synced in local {folder} with the remote path stripped of its leading slash. The wizard rows keep the Remote: label. Closes #78 --- src/main.rs | 15 +++++++++++++++ src/ui/folder_status.rs | 17 ++++++++++++----- src/util/translations/es.rs | 1 + 3 files changed, 28 insertions(+), 5 deletions(-) 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/ui/folder_status.rs b/src/ui/folder_status.rs index 3c09b02..7c0b466 100644 --- a/src/ui/folder_status.rs +++ b/src/ui/folder_status.rs @@ -41,9 +41,12 @@ 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", @@ -169,6 +172,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") @@ -414,7 +420,8 @@ 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"); reset_locale(); } @@ -496,7 +503,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 +513,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/util/translations/es.rs b/src/util/translations/es.rs index bbfafac..29c0147 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -365,6 +365,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Sync Now", "Sincronizar ahora"), ("Sync Once", "Sincronizar una vez"), ("Sync provider", "Proveedor de sincronización"), + ("Synced in local {folder}", "Sincronizado en local {folder}"), ("Synchronization", "Sincronización"), ("Synchronization Engine", "Motor de sincronización"), ("Synchronization Error", "Error de sincronización"), From 45b790ca301ae0dc4436560efe100a4a205fadbd Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 13:46:31 +0200 Subject: [PATCH 10/18] fix(ui): both account-removal dialogs say that no files are deleted Step one mentioned the local Nextcloud folder only and step two, the typed confirmation, said nothing about files at all. Both steps now state plainly that nothing is deleted locally or on the server and the wording is provider-neutral. Closes #79 --- po/es.po | 13 ++++++++----- src/ui/settings.rs | 4 ++-- src/util/translations/es.rs | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/po/es.po b/po/es.po index 4457bb3..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”" 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/util/translations/es.rs b/src/util/translations/es.rs index 29c0147..8b227f9 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -225,6 +225,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("No OpenCloud space was discovered for this account. Sign in again to retry the discovery.", "No se ha descubierto ningún espacio de OpenCloud para esta cuenta. Vuelve a iniciar sesión para reintentar el descubrimiento."), ("No Synchronization Folders", "Sin carpetas de sincronización"), ("No conflicted copies found in {folder}.", "No se encontraron copias en conflicto en {folder}."), + ("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.", "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."), ("No pending local changes since the last synchronization.", "No hay cambios locales pendientes desde la última sincronización."), ("No restorable files were found in the server trash.", "No se encontraron archivos recuperables en la papelera del servidor."), ("No saved credentials", "No hay credenciales guardadas"), @@ -391,7 +392,6 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Syncing", "Sincronizando"), ("System", "Sistema"), ("Terms of Use", "Términos de uso"), - ("The account credential will be removed from the password keyring. Your local NextCloud folder and all files inside it will remain untouched.", "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."), ("The account view lists every sync folder of the active account.", "La vista de la cuenta muestra todas las carpetas de sincronización de la cuenta activa."), ("The account will be connected without synchronizing any folder. You can add folders later from Settings.", "La cuenta se conectará sin sincronizar ninguna carpeta. Puedes añadir carpetas más tarde desde Ajustes."), ("The address of your Nextcloud or OpenCloud server", "La dirección de tu servidor Nextcloud u OpenCloud"), @@ -418,7 +418,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Two accounts against the same server, user, and local folder but different remote folders are now treated as distinct accounts.", "Dos cuentas contra el mismo servidor, usuario y carpeta local pero con carpetas remotas distintas ahora se consideran cuentas diferentes."), ("Type “remove”", "Escribe «remove»"), ("Type “remove” to confirm account removal.", "Escribe «remove» para confirmar la eliminación de la cuenta."), - ("Type “remove” to confirm. This cannot be undone and stops synchronization immediately.", "Escribe «remove» para confirmar. Esta acción no se puede deshacer y detiene la sincronización de inmediato."), + ("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.", "Escribe «remove» para confirmar. Esta acción no se puede deshacer y detiene 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."), ("Unlock Password Keyring", "Desbloquear llavero de contraseñas"), ("Update Available", "Actualización disponible"), ("Update windows are no longer reassociated after becoming visible, preventing placement on another monitor at the desktop origin.", "Las ventanas de actualización ya no se reasocian después de hacerse visibles, lo que evita que aparezcan en otro monitor en el origen del escritorio."), From 70b6c4770ed8ab34845b9f781e5f019bb781579b Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:23:44 +0200 Subject: [PATCH 11/18] feat(ui): two-line account summary with accented Add Folder The summary squeezed state, server and full quota into one line. It now stacks two lines: state light and server host, then login user and used space only, with no capacity. Add Folder sits on the right with the suggested-action class so it renders in the theme accent. Closes #81 --- src/ui/main_window.rs | 64 +++++++++++++++++++++++++------------ src/util/translations/es.rs | 1 + 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index a20c2d7..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); }); } diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 8b227f9..c5b4f9a 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -473,4 +473,5 @@ pub static CATALOG: &[(&str, &str)] = &[ ("{count} conflicted copy(ies) found in {folder}.", "Se encontraron {count} copia(s) en conflicto en {folder}."), ("{count} of {total} items were restored from the server trash.", "Se restauraron {count} de {total} elementos de la papelera del servidor."), ("{size} local", "{size} en local"), + ("{used} used", "{used} usados"), ]; From b9606826620ec30158e4b76022bc3d9d5b69c9d0 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:25:30 +0200 Subject: [PATCH 12/18] feat(ui): list existing remote folders in the wizard add-folder dialog First-run setup asked for the remote path as plain text. The dialog now offers a dropdown with the server's existing folders, filled right after sign-in with the credentials already entered in the wizard; the manual entry stays the source of truth for new paths. OpenCloud keeps the manual entry until its listing lands. Closes #82 --- src/ui/setup.rs | 70 +++++++++++++++++++++++++++++++++++++ src/util/translations/es.rs | 1 + 2 files changed, 71 insertions(+) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 3445a98..b369465 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -1068,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)); } @@ -1730,6 +1758,48 @@ fn fold_home_with(home: Option<&str>, path: &str) -> String { 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. fn choose_local_folder(entry: libadwaita::EntryRow) { let dialog = gtk4::FileDialog::builder() diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index c5b4f9a..71de2b9 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -97,6 +97,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Could Not Add the Account", "No se pudo añadir la cuenta"), ("Could Not Check for Updates", "No se pudieron buscar actualizaciones"), ("Could not authenticate with the server.", "No se pudo autenticar con el servidor."), + ("Could not list the remote folders.", "No se pudieron listar las carpetas remotas."), ("Could not reach the server.", "No se pudo contactar con el servidor."), ("Could not remove the folder", "No se pudo eliminar la carpeta"), ("Could not save credentials.", "No se han podido guardar las credenciales."), From 7ffc4053185bb21b88a1e8f197bb29cb732fdd06 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:25:41 +0200 Subject: [PATCH 13/18] style(icons): stroke width 3 for the tray glyphs The Lucide-default stroke of 2 read thin and washed out at tray sizes; 3 keeps the geometry legible on panels. Closes #83 --- data/icons/nextsync-tray-cloud-check.svg | 2 +- data/icons/nextsync-tray-cloud-off.svg | 2 +- data/icons/nextsync-tray-cloud.svg | 2 +- data/icons/nextsync-tray-settings.svg | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/data/icons/nextsync-tray-cloud-check.svg b/data/icons/nextsync-tray-cloud-check.svg index adbe126..af94e12 100644 --- a/data/icons/nextsync-tray-cloud-check.svg +++ b/data/icons/nextsync-tray-cloud-check.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-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.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" > From 3fd30f8a6d7d0ec2f90c22bad65cc2bbe5465253 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:27:34 +0200 Subject: [PATCH 14/18] feat(tray): drop Settings and Pause Everything from the menu Both entries duplicate controls that live in the main window, one Open click away. The tray menu keeps Open, Log and Quit; the pause-all state handling stays for the header button. Closes #84 --- src/ui/tray.rs | 102 +++++++++++++------------------------------------ 1 file changed, 26 insertions(+), 76 deletions(-) diff --git a/src/ui/tray.rs b/src/ui/tray.rs index 4afc2c8..b3fac39 100644 --- a/src/ui/tray.rs +++ b/src/ui/tray.rs @@ -111,8 +111,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. @@ -156,29 +156,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> { + // Issue #84: Settings and Pause Everything left the tray menu; both + // live in the main window, one Open click away. The menu keeps Open, + // Log (when wired) and Quit. let open = self.actions.clone(); - let settings = self.actions.clone(); let quit = self.actions.clone(); - let mut items: Vec> = vec![ - StandardItem { - label: t("Open NextSync").into(), - icon_name: "window-new-symbolic".into(), - activate: Box::new(move |_this: &mut Self| { - let _ = open.try_send(TrayAction::Open); - }), - ..Default::default() - } - .into(), - StandardItem { - label: t("Settings").into(), - icon_name: "nextsync-tray-settings".into(), - activate: Box::new(move |_this: &mut Self| { - let _ = settings.try_send(TrayAction::Settings); - }), - ..Default::default() - } - .into(), - ]; + let mut items: Vec> = vec![StandardItem { + label: t("Open NextSync").into(), + icon_name: "window-new-symbolic".into(), + activate: Box::new(move |_this: &mut Self| { + let _ = open.try_send(TrayAction::Open); + }), + ..Default::default() + } + .into()]; if self.show_conflicts { let conflicts = self.actions.clone(); items.push( @@ -196,26 +187,6 @@ impl TrayItem { .into(), ); } - // Pause/resume every account at once (issue #42). The label asks for - // the action that is currently NOT active. - let pause = self.actions.clone(); - let label = if self.all_paused { - t("Resume Everything") - } else { - t("Pause Everything") - }; - items.push( - StandardItem { - label: label.into(), - icon_name: "media-playback-pause-symbolic".into(), - activate: Box::new(move |this: &mut Self| { - let next = !this.all_paused; - let _ = pause.try_send(TrayAction::PauseAll(next)); - }), - ..Default::default() - } - .into(), - ); items.push( StandardItem { label: t("Quit").into(), @@ -356,7 +327,7 @@ mod tests { } #[test] - fn menu_has_five_items_when_conflicts_is_wired() { + fn menu_has_three_items_when_conflicts_is_wired() { set_locale(Locale::English); let (item, _rx) = item_with(AppState::IdleOk); let menu = item.build_menu(); @@ -368,16 +339,8 @@ mod tests { _ => panic!("unexpected menu item type"), }) .collect(); - assert_eq!( - labels, - vec![ - "Open NextSync", - "Settings", - "Log", - "Pause Everything", - "Quit" - ] - ); + // Settings and Pause Everything live in the main window (issue #84). + assert_eq!(labels, vec!["Open NextSync", "Log", "Quit"]); reset_locale(); } @@ -394,28 +357,26 @@ mod tests { _ => panic!("unexpected menu item type"), }) .collect(); - assert_eq!( - labels, - vec!["Open NextSync", "Settings", "Pause Everything", "Quit"] - ); + assert_eq!(labels, vec!["Open NextSync", "Quit"]); reset_locale(); } #[test] - fn menu_pause_label_flips_when_everything_is_paused() { + fn menu_has_no_pause_or_settings_entry() { set_locale(Locale::English); let (mut item, _rx) = item_with(AppState::PausedUser); item.set_all_paused(true); - let menu = item.build_menu(); - let labels: Vec<&str> = menu + let labels: Vec = item + .build_menu() .iter() .map(|entry| match entry { - MenuItem::Standard(standard) => standard.label.as_str(), + MenuItem::Standard(standard) => standard.label.clone(), _ => panic!("unexpected menu item type"), }) .collect(); - assert!(labels.contains(&"Resume Everything")); - assert!(!labels.contains(&"Pause Everything")); + assert!(!labels.iter().any(|l| l.contains("Pause"))); + assert!(!labels.iter().any(|l| l.contains("Resume"))); + assert!(!labels.iter().any(|l| l == "Settings")); reset_locale(); } @@ -431,16 +392,7 @@ mod tests { _ => panic!("unexpected menu item type"), }) .collect(); - assert_eq!( - labels, - vec![ - "Abrir NextSync", - "Configuración", - "Registro", - "Pausar todo", - "Salir" - ] - ); + assert_eq!(labels, vec!["Abrir NextSync", "Registro", "Salir"]); reset_locale(); } @@ -455,9 +407,7 @@ mod tests { } } assert_eq!(rx.try_recv().unwrap(), TrayAction::Open); - assert_eq!(rx.try_recv().unwrap(), TrayAction::Settings); assert_eq!(rx.try_recv().unwrap(), TrayAction::Conflicts); - assert_eq!(rx.try_recv().unwrap(), TrayAction::PauseAll(true)); assert_eq!(rx.try_recv().unwrap(), TrayAction::Quit); assert!(rx.try_recv().is_err(), "no extra actions should be sent"); } From 057cf1eb174988211fb566c6a2994ab0617ae6c3 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:33:51 +0200 Subject: [PATCH 15/18] fix(core): retry transient keyring failures instead of latching auth-required Killing and relaunching the app left every folder in needs-attention until a manual sync: a credential lookup that failed for infrastructure reasons (session bus not ready at startup, secret service hiccup) was classified as rejected credentials, which armed the #72 gate and parked all automatic triggers. Locked and unavailable lookups now report the keyring as locked, a state with no brute-force risk, and the scheduler re-attempts the run on the next automatic trigger instead of only queuing it. A genuinely missing keyring item keeps the auth-rejected meaning. Closes #85 --- src/core/scheduler.rs | 21 +++++++++-------- src/nextcloud/sync_engine.rs | 44 +++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 12 deletions(-) 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/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(); From fc44da32a3531a38fa6b0edfecbfea92f690dbf7 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:39:21 +0200 Subject: [PATCH 16/18] feat(ui): live per-file progress on the folder rows While a run is in flight the row showed a bare spinner, so large folders looked frozen. The row now renders a small dim line with the translated action, the file being operated on and the run's operation counter, driven by the progress events the engine already publishes; it disappears when the run ends. Closes #86 --- src/ui/folder_status.rs | 65 ++++++++++++++++++++++++++++++++++++- src/util/translations/es.rs | 6 ++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/ui/folder_status.rs b/src/ui/folder_status.rs index 7c0b466..df038bb 100644 --- a/src/ui/folder_status.rs +++ b/src/ui/folder_status.rs @@ -49,12 +49,36 @@ pub fn remote_label(remote_path: &str) -> String { 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). /// @@ -144,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 { @@ -191,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); @@ -277,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 { @@ -302,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); @@ -422,6 +479,12 @@ mod tests { assert_eq!(label, "Sin conexión"); 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(); } diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 71de2b9..30e992d 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -467,9 +467,15 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Your complete Nextcloud file tree will be stored physically on this computer and synchronized in both directions.", "Tu árbol de archivos completo de Nextcloud se almacenará en este equipo y se sincronizará en ambos sentidos."), ("Your complete file tree will be stored physically on this computer and synchronized in both directions.", "Tu árbol de archivos completo se almacenará físicamente en este equipo y se sincronizará en ambas direcciones."), ("and {count} more…", "y {count} más…"), + ("conflict on {file}", "conflicto en {file}"), + ("deleting {file}", "eliminando {file}"), + ("downloading {file}", "descargando {file}"), ("nextcloudcmd is missing. Install the nextcloud-desktop-cmd package before the first synchronization.", "Falta nextcloudcmd. Instala el paquete nextcloud-desktop-cmd antes de la primera sincronización."), ("opencloudcmd is missing. Install the OpenCloud desktop package before the first synchronization.", "Falta opencloudcmd. Instala el paquete de escritorio de OpenCloud antes de la primera sincronización."), + ("processing {file}", "procesando {file}"), + ("uploading {file}", "subiendo {file}"), ("{account}: open NextSync to check the account credential.", "{account}: abre NextSync para comprobar la credencial de la cuenta."), + ("{action} · {count}", "{action} · {count}"), ("{count} changes in this release", "{count} cambios en esta versión"), ("{count} conflicted copy(ies) found in {folder}.", "Se encontraron {count} copia(s) en conflicto en {folder}."), ("{count} of {total} items were restored from the server trash.", "Se restauraron {count} de {total} elementos de la papelera del servidor."), From bf554d4ae54a46ae8c63a1dc525340908b54ef14 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 14:48:27 +0200 Subject: [PATCH 17/18] feat(tray): state-specific glyphs for syncing, problems and unconfigured The tray showed the plain cloud for everything but all-synced and unconfigured. Syncing and queued states now show the Lucide cloud-sync swirl, problem states (error, auth required, keyring locked) show cloud-alert, and the unconfigured install shows the canonical Lucide cloud-off instead of the old struck-out cloud. All glyphs keep the stroke width 3 of the tray family. Closes #87 --- PKGBUILD | 2 ++ data/icons/nextsync-tray-cloud-alert.svg | 15 +++++++++++ data/icons/nextsync-tray-cloud-sync.svg | 17 ++++++++++++ src/ui/tray.rs | 34 +++++++++++++++++------- 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 data/icons/nextsync-tray-cloud-alert.svg create mode 100644 data/icons/nextsync-tray-cloud-sync.svg diff --git a/PKGBUILD b/PKGBUILD index e646553..be0f553 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -63,8 +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-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/src/ui/tray.rs b/src/ui/tray.rs index b3fac39..d9370bb 100644 --- a/src/ui/tray.rs +++ b/src/ui/tray.rs @@ -27,7 +27,8 @@ //! 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-check.svg`, `nextsync-tray-cloud-off.svg`, +//! `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). @@ -79,17 +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) with one -/// addition: an unconfigured install shows the struck-out cloud so the user -/// is not left guessing that sync is merely paused, and the all-synced -/// aggregate (`icon_key` "ok", IdleOk) shows a cloud with a check so healthy -/// is visible at a glance; 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" } @@ -435,11 +439,21 @@ mod tests { let (idle, _rx) = item_with(AppState::IdleOk); assert_eq!(idle.icon_name(), "nextsync-tray-cloud-check"); - // Paused and error states keep the plain cloud. + // A running or queued sync shows the swirl (issue #87). + let (syncing, _rx) = item_with(AppState::Syncing); + assert_eq!(syncing.icon_name(), "nextsync-tray-cloud-sync"); + let (queued, _rx) = item_with(AppState::SyncQueued); + assert_eq!(queued.icon_name(), "nextsync-tray-cloud-sync"); + + // Problem states show cloud-alert (issue #87). + let (error, _rx) = item_with(AppState::Error); + assert_eq!(error.icon_name(), "nextsync-tray-cloud-alert"); + let (auth, _rx) = item_with(AppState::AuthRequired); + assert_eq!(auth.icon_name(), "nextsync-tray-cloud-alert"); + + // Paused keeps the plain cloud. let (paused, _rx) = item_with(AppState::PausedUser); assert_eq!(paused.icon_name(), "nextsync-tray-cloud"); - let (error, _rx) = item_with(AppState::Error); - assert_eq!(error.icon_name(), "nextsync-tray-cloud"); } #[test] From 38b5330f56fd01c165d18c706fdccd9bf2961bea Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 20 Aug 2026 16:40:14 +0200 Subject: [PATCH 18/18] fix(api): percent-decode remote folder names from WebDAV hrefs Remote folders with spaces showed up percent-encoded in the pickers (Mis%20documentos), and picking one stored the encoded name, which the engine re-encoded into a %2520 URL and a 404 on every sync. The listing now decodes the href segment so pickers show and store the real name; the engine's single encoding stays the only one. Round-trip tests cover spaces, accents and malformed escapes. Closes #88 --- src/nextcloud/api.rs | 57 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs index a521142..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(); @@ -1003,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!( @@ -1236,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 @@ -1841,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] @@ -2340,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() {