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> { + // 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( @@ -191,26 +191,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(), @@ -351,7 +331,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(); @@ -363,16 +343,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(); } @@ -389,28 +361,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(); } @@ -426,16 +396,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(); } @@ -450,9 +411,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"); } @@ -476,8 +435,25 @@ 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"); + + // 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"); } #[test] diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 528baa3..30e992d 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."), @@ -170,11 +171,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"), @@ -219,6 +226,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"), @@ -307,6 +315,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…"), @@ -331,7 +340,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"), @@ -359,6 +367,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"), @@ -384,7 +393,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"), @@ -402,7 +410,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."), @@ -411,7 +419,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."), @@ -459,11 +467,18 @@ 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."), ("{size} local", "{size} en local"), + ("{used} used", "{used} usados"), ];