From bfc85564bf90750792d7bc5f3a1a8f75c22af10b Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:05 +0200 Subject: [PATCH 01/14] fix(core): delete the conflicted copy when keeping the remote one keep_remote promoted the conflicted copy over the working file but left the '(Nextcloud conflicted copy ...)' file on disk, so the next discovery reported the same conflict and nextcloudcmd reprocessed it every run. Closes #120 --- src/core/conflict_files.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/core/conflict_files.rs b/src/core/conflict_files.rs index 97bbe8d..b45e08e 100644 --- a/src/core/conflict_files.rs +++ b/src/core/conflict_files.rs @@ -280,6 +280,16 @@ pub fn keep_remote(conflict: &ConflictFile) -> bool { .write(true) .open(&conflict.original_path)? .set_times(fs::FileTimes::new().set_modified(modified))?; + // The conflicted copy was promoted; remove it so the next discovery + // does not report the same conflict again (and the engine does not + // reprocess it on the following run). + match fs::remove_file(&conflict.path) { + Ok(()) => {} + // The copy may already be gone; promoting the content is what + // matters. + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => {} + } Ok(()) })() .is_ok() @@ -540,6 +550,7 @@ mod tests { assert!(keep_remote(&conflicts[0])); assert!(original.exists()); assert_eq!(fs::read_to_string(&original).unwrap(), "remote-content"); + assert!(!dir.path().join(name).exists()); } // ---- helpers ------------------------------------------------------------ From bc1cc77592362ce0543cb1760d16fcb8fc857268 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:09 +0200 Subject: [PATCH 02/14] fix(ui): unsubscribe folder row subscriptions when the row drops Each FolderStatusRow held its state and progress subscriptions without a Drop impl, so rows discarded while rebuilding the account view left their callbacks alive on the StateController, capturing row widgets. That is the same crash vector as #102 plus an unbounded listener leak. Closes #121 --- src/ui/folder_status.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ui/folder_status.rs b/src/ui/folder_status.rs index df2fbcd..2bc415d 100644 --- a/src/ui/folder_status.rs +++ b/src/ui/folder_status.rs @@ -460,6 +460,21 @@ impl FolderStatusRow { } } +impl Drop for FolderStatusRow { + /// Unsubscribe the live state/progress callbacks so they stop touching the + /// row's widgets once the row leaves the tree (issue #121). A plain drop + /// keeps the subscription active and leaks the closures, the same crash + /// vector fixed for the account view in #102. + fn drop(&mut self) { + if let Some(mut subscription) = self._subscription.take() { + subscription.unsubscribe(); + } + if let Some(mut progress) = self._progress_subscription.take() { + progress.unsubscribe(); + } + } +} + /// Detached control of the pending-changes menu item (issue #92): the scan /// runs off the UI thread and reports through [`PendingMenuHandle`]. #[derive(Clone)] From afafbe2faa7ec0dfb6c4a5ca0405b06b5cb53bd7 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:22 +0200 Subject: [PATCH 03/14] fix(ui): reset the cached conflicts window when it closes show_conflicts_for cached a single ConflictResolverWindow and never cleared it: on_close was None, so opening 'Resolve conflicts' from another folder raised the stale window (wrong folder's conflicts and deletions) and its Recent poll stayed stopped after the first close. Closes #122 --- src/ui/main_window.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 8ccdc7a..8db8283 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -912,13 +912,19 @@ impl MainWindow { .get(account_id) .and_then(|runtime| runtime.folders().get(folder_id).cloned()) .map(|folder| folder.scheduler()); + let self_weak = self.self_weak.clone(); + let on_close = Rc::new(move || { + if let Some(main) = self_weak.upgrade() { + main.borrow_mut().conflicts_window = None; + } + }); let window = crate::ui::conflict_resolver::ConflictResolverWindow::new( &self.application, &folder.local_root, matcher, Rc::new(logger), scheduler, - None, + Some(on_close), ); window.present(); self.conflicts_window = Some(window); From b537ae6107468f25501c1f01b7f35fa939e133e6 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:27 +0200 Subject: [PATCH 04/14] fix(ui): keep the summary connection text in sync with the state The 'Connected'/'Not connected' label was set once at construction while only the light icon updated live, so the text stayed 'Connected' after the connection dropped. The aggregate subscription now refreshes both, and the text follows the actual state (only Offline reads 'Not connected'). Closes #129 --- src/ui/main_window.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 8db8283..3654a5a 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -203,11 +203,7 @@ impl AccountView { // The status label doubles as the anti-race guard for the // background quota fetch (detached rows keep their text). let name_label = gtk4::Label::builder() - .label(if connected { - t("Connected") - } else { - t("Not connected") - }) + .label(summary_connection_text(runtime.state().snapshot().state)) .xalign(0.0) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); @@ -465,8 +461,10 @@ impl AccountView { // (the Sync/Pause buttons are gone; the light carries the state). let aggregate = runtime.state(); let light = light.clone(); + let name_label = name_label.clone(); let subscription = aggregate.subscribe(move |snapshot: &StateSnapshot| { light.set_icon_name(Some(summary_light_for(snapshot.state))); + name_label.set_text(summary_connection_text(snapshot.state)); }); Self { @@ -1894,6 +1892,18 @@ pub fn summary_light_for(state: crate::state::AppState) -> &'static str { } } +/// The connection text for the account summary card. Only `Offline` reads +/// "Not connected"; every other state implies the server is reachable (the +/// light already carries the severity, issue #129). +pub fn summary_connection_text(state: crate::state::AppState) -> &'static str { + use crate::state::AppState; + if state == AppState::Offline { + t("Not connected") + } else { + t("Connected") + } +} + /// Host part of a server URL (`https://cloud.example.com` -> /// `cloud.example.com`); the raw URL when it does not parse as expected. pub use crate::util::url::server_host; From 693509352975cf5f6de24b7e59380f320b0e3595 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:38 +0200 Subject: [PATCH 05/14] fix(api): percent-encode WebDAV path segments WebDAV URLs were built from the raw remote_path and username, so folders with spaces, accents or reserved characters ('Musica Albums', 'My Documents') produced invalid URIs and MKCOL/PROPFIND failed with a transport error. Each path segment and the username are now percent-encoded (restore_trash_item already did this; the rest did not). Closes #123 --- src/nextcloud/api.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs index 091dc27..b92b10c 100644 --- a/src/nextcloud/api.rs +++ b/src/nextcloud/api.rs @@ -397,7 +397,7 @@ impl NextcloudApi { if !accumulated.is_empty() { accumulated.push('/'); } - accumulated.push_str(segment); + accumulated.push_str(&percent_encode_path(segment)); let url = format!("{base}/{accumulated}"); let response = self.http.request( "MKCOL", @@ -428,7 +428,10 @@ impl NextcloudApi { remote_path: &str, ) -> Result { let base = dav_base(server, username); - let folder = format!("{base}{}/", remote_path.trim_end_matches('/')); + let folder = format!( + "{base}{}/", + percent_encode_path(remote_path.trim_end_matches('/')) + ); let folder_path = href_path_of(&folder).to_owned(); let entries = self.propfind(&folder, username, password)?; let children = entries @@ -454,7 +457,10 @@ impl NextcloudApi { remote_path: &str, ) -> Result, ApiError> { let base = dav_base(server, username); - let folder = format!("{base}{}/", remote_path.trim_end_matches('/')); + let folder = format!( + "{base}{}/", + percent_encode_path(remote_path.trim_end_matches('/')) + ); let authorization = basic_authorization(username, password); let headers = [ ("Depth", "infinity"), @@ -816,8 +822,9 @@ impl NextcloudApi { password: &str, ) -> Result, ApiError> { let url = format!( - "{}/remote.php/dav/trashbin/{username}/trash", - server.trim_end_matches('/') + "{}/remote.php/dav/trashbin/{}/trash", + server.trim_end_matches('/'), + percent_encode_path(username) ); let authorization = basic_authorization(username, password); let headers = [ @@ -1034,8 +1041,9 @@ fn percent_decode_path(value: &str) -> String { /// Base URL of the per-user WebDAV root. fn dav_base(server: &str, username: &str) -> String { format!( - "{}/remote.php/dav/files/{username}", - server.trim_end_matches('/') + "{}/remote.php/dav/files/{}", + server.trim_end_matches('/'), + percent_encode_path(username) ) } From 0f50beb05168023eba831086d463bd323718b3a2 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:43 +0200 Subject: [PATCH 06/14] fix(api): filter the remote folder list by the folder name only The picker filtered every path segment, including the username and the fixed dav root, so a login containing 'trash' or 'versions' hid all folders and a legit folder named '.dotfiles' disappeared too. The filter now looks at the last segment (the folder name) only. Closes #128 --- src/nextcloud/api.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs index b92b10c..fedaae0 100644 --- a/src/nextcloud/api.rs +++ b/src/nextcloud/api.rs @@ -541,19 +541,21 @@ impl NextcloudApi { if !entry.is_collection { continue; } - let segments: Vec<&str> = entry.href_path.split('/').collect(); - if segments.iter().any(|segment| { - segment.starts_with('.') - || segment.contains("trashbin") - || segment.contains("trash") - || segment.contains("versions") - }) { + // Filter only the folder name (the last path segment): earlier + // segments include the username and the fixed dav root, whose + // substrings must not hide every folder (issue #128). + let name = entry.href_path.rsplit('/').next().unwrap_or_default(); + if name.starts_with('.') + || name.contains("trashbin") + || name.contains("trash") + || name.contains("versions") + { continue; } // 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()); + let name = percent_decode_path(name); folders.push(format!("/{name}")); } folders.sort(); From 6cf5d3adb8326f4c2028755b1688cd74a391e253 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:47 +0200 Subject: [PATCH 07/14] fix(engine): scrub the account secret from the captured output The sync engine only ran the structural redaction passes, so a password or token echoed by nextcloudcmd on its diagnostics was stored verbatim in the run tail. The resolved credential now feeds a Redactor that scrubs it before the line reaches the capture buffer or the log. Closes #126 --- src/nextcloud/sync_engine.rs | 37 ++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/nextcloud/sync_engine.rs b/src/nextcloud/sync_engine.rs index d4782b6..2af55ef 100644 --- a/src/nextcloud/sync_engine.rs +++ b/src/nextcloud/sync_engine.rs @@ -45,7 +45,7 @@ use crate::nextcloud::command::{BoundedOutputCapture, Classification, DEFAULT_MA use crate::nextcloud::credentials::CredentialsStore; use crate::nextcloud::driver::{driver_for, DriverContext, Provider}; use crate::storage::config::{AccountConfig, FolderConfig, NetworkConfig}; -use crate::util::redact::Redact; +use crate::util::redact::Redactor; /// Result of looking up the account password. #[derive(Debug, Clone, PartialEq, Eq)] @@ -331,6 +331,10 @@ fn engine_thread( } CredentialLookup::Missing => return EngineRun::Direct(SyncOutcome::NoCredentials), }; + // Scrub the known secret from the captured output: nextcloudcmd can echo + // the password/token on its diagnostics, and the structural passes alone + // would leave it verbatim in the run tail (issue #126). + let redactor = Arc::new(Redactor::from_secrets([password.clone()])); // `nextcloudcmd` exits 1 with no output when the remote folder does not // exist; create it (and its parents) first. Auth rejection surfaces as // such; anything else falls through and lets nextcloudcmd report. @@ -386,6 +390,7 @@ fn engine_thread( Arc::clone(&capture), Arc::clone(&processed), Arc::clone(&conflict_signal), + Arc::clone(&redactor), ); let stderr_handle = drain_stream( stderr, @@ -393,6 +398,7 @@ fn engine_thread( capture.clone(), processed.clone(), conflict_signal.clone(), + redactor, ); let exit_code = wait_for_exit(&process); let _ = stdout_handle.join(); @@ -428,12 +434,13 @@ fn drain_stream( capture: Arc>, processed: Arc, conflict_signal: Arc, + redactor: Arc, ) -> JoinHandle<()> { std::thread::spawn(move || { let reader = std::io::BufReader::new(stream); for line in reader.lines() { let Ok(line) = line else { break }; - let line = Redact::redact_line(&line); + let line = redactor.redact(&line); match parse_progress_line(&line) { Some(parsed) => { let processed = @@ -774,6 +781,32 @@ mod tests { assert_eq!(events[0].action, "download"); } + #[test] + fn engine_output_has_the_password_redacted() { + // Issue #126: the engine can echo the password/token on its own + // diagnostics; the run tail must scrub it with the known-secret + // redactor, not only the structural passes. + let capture = Arc::new(Mutex::new(BoundedOutputCapture::new(DEFAULT_MAX_LINES))); + let processed = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let conflict_signal = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (progress_tx, progress_rx) = async_channel::unbounded(); + let redactor = Arc::new(Redactor::from_secrets(["s3cr3t-t0ken-here"])); + let line = format!("Downloading using token s3cr3t-t0ken-here\n"); + let handle = drain_stream( + std::io::Cursor::new(line), + progress_tx, + Arc::clone(&capture), + Arc::clone(&processed), + Arc::clone(&conflict_signal), + Arc::clone(&redactor), + ); + handle.join().expect("drain thread joined"); + let output = capture.lock().expect("capture mutex poisoned").output(); + assert!(!output.contains("s3cr3t-t0ken-here")); + assert!(output.contains("[REDACTED]")); + assert!(progress_rx.try_recv().is_err()); + } + #[test] fn opencloud_missing_space_id_maps_to_failed() { let mut account = account(); From ebd549a4176789f6b0f3b2731b8e4066fc82c238 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:50 +0200 Subject: [PATCH 08/14] fix(ui): detect finished runs by their outcome markers, not English text The Recent tab matched only the English substring 'completed successfully', so localized outcome lines (the ones actually written to the daily log) got the INFO icon instead of the OK check. Success is now recognized from the fixed outcome msgids and their Spanish catalog forms. Closes #125 --- src/ui/activity.rs | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/ui/activity.rs b/src/ui/activity.rs index 429dba1..a6b9ec7 100644 --- a/src/ui/activity.rs +++ b/src/ui/activity.rs @@ -53,12 +53,11 @@ pub struct ActivityEntry { impl ActivityEntry { /// Build the entry icon name: successful `INFO` lines get the OK emblem. fn with_icon(level: String, message: String) -> Self { - let icon_name = - if level == "INFO" && message.to_lowercase().contains("completed successfully") { - OK_ICON.to_string() - } else { - level_icon(&level).to_string() - }; + let icon_name = if level == "INFO" && is_success_line(&message) { + OK_ICON.to_string() + } else { + level_icon(&level).to_string() + }; Self { level, message, @@ -67,6 +66,16 @@ impl ActivityEntry { } } +/// Whether a log line reports a finished run. `outcome_log_line` +/// (account_runtime.rs) emits the fixed msgids "Synchronization completed" +/// / "Synchronization completed with conflicts"; match them and their Spanish +/// catalog forms (issue #125). The old English-only substring check never +/// matched the localized lines actually written to the daily log. +fn is_success_line(message: &str) -> bool { + let lowered = message.to_lowercase(); + lowered.contains("synchronization completed") || lowered.contains("sincronización completada") +} + impl fmt::Display for ActivityEntry { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "[{}] {}", self.level, self.message) @@ -188,6 +197,22 @@ mod tests { assert_eq!(entry.icon_name, "nextsync-activity-ok"); } + #[test] + fn spanish_outcome_line_gets_the_ok_icon() { + let entry = parse_activity_line( + "2026-08-07 14:12:41 INFO nacho@host · /home/user/Files: Sincronización completada", + ); + assert_eq!(entry.icon_name, "nextsync-activity-ok"); + } + + #[test] + fn conflict_outcome_line_gets_the_ok_icon() { + let entry = parse_activity_line( + "2026-08-07 14:12:41 INFO alice@host · /home/alice/Nextcloud: Synchronization completed with conflicts", + ); + assert_eq!(entry.icon_name, "nextsync-activity-ok"); + } + #[test] fn plain_line_falls_back_to_info() { let entry = parse_activity_line("nextcloudcmd started"); From 5245d2ea2ad4d5b0d7997a1ba4f9a46ae7516db4 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:03:54 +0200 Subject: [PATCH 09/14] fix(scheduler): start a trigger queued during the cooldown cooldown_finished used the queued snapshot captured when the run ended, so a trigger arriving during the 4s cooldown was queued but never started until the next trigger (Manual included). It now re-reads the real queue and starts if anything is pending, online and not paused. Closes #127 --- src/core/scheduler.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/core/scheduler.rs b/src/core/scheduler.rs index fb8b47a..a7a167a 100644 --- a/src/core/scheduler.rs +++ b/src/core/scheduler.rs @@ -696,7 +696,6 @@ impl SchedulerInner { } self.running = false; if ran { - let mut queued = !self.queue.is_empty(); if self.inotify_during_sync { if feedback_followup { // Suppress only the local feedback from the reconciliation @@ -706,7 +705,6 @@ impl SchedulerInner { self.feedback_followup_pending = true; self.queue.add(Trigger::LocalInotify); } - queued = !self.queue.is_empty(); } self.inotify_during_sync = false; self.state.set_progress(None); @@ -716,7 +714,7 @@ impl SchedulerInner { let weak = self.self_ref.clone(); self.debounce().begin_cooldown(move || { if let Some(inner) = weak.upgrade() { - inner.borrow_mut().cooldown_finished(queued); + inner.borrow_mut().cooldown_finished(); } }); } else { @@ -727,11 +725,15 @@ impl SchedulerInner { } } - fn cooldown_finished(&mut self, run_pending: bool) { + fn cooldown_finished(&mut self) { if self.stopped { return; } - if run_pending && !self.queue.is_empty() && self.online && !self.paused() { + // Re-read the real queue instead of the snapshot captured when the + // run ended: a trigger arriving during the 4s cooldown was queued + // (schedule_start aborts on in_cooldown) and must start now + // (issue #127). + if !self.queue.is_empty() && self.online && !self.paused() { self.schedule_start(); } else if !self.paused() { self.set_idle_state(); @@ -1367,6 +1369,26 @@ mod tests { assert_eq!(scheduler.state().snapshot().state, AppState::IdleOk); } + #[test] + fn trigger_queued_during_cooldown_starts_when_it_elapses() { + // Issue #127: a trigger arriving while the 4s cooldown is pending is + // queued (schedule_start aborts on in_cooldown); when the cooldown + // finishes it must start, not park in idle because of the snapshot + // captured at run end. + let (scheduler, source, runner) = make_scheduler(None); + scheduler.request(Trigger::Manual); + run_idle(&source); + finish(&runner, SyncOutcome::Success); + // The run ended cleanly with an empty queue; a new trigger arrives + // during the cooldown. + assert!(source.borrow().pending() >= 1); + scheduler.request(Trigger::Manual); + assert!(scheduler.queue_len() >= 1); + run_idle(&source); // cooldown elapses with the queue non-empty + run_idle(&source); // the scheduled start fires + assert_eq!(runner.0.borrow().start_calls, 2); + } + #[test] fn delete_alert_blocks_and_approve_once_bypasses() { let (scheduler, source, runner) = make_scheduler(None); From f7db5f8cf8ef0544e0a20effd21f63bc689f3f92 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:04:05 +0200 Subject: [PATCH 10/14] fix(ui): translate the account-removal toast The 'Type \u201cremove\u201d to confirm account removal.' toast was hardcoded English even though the catalog already carried its Spanish translation. Closes #130 --- src/ui/settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 74dd574..19b7f3b 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2137,7 +2137,7 @@ fn present_remove_account_step_two( return; } if !typed_confirmation_matches(&entry.text()) { - let toast = libadwaita::Toast::new("Type “remove” to confirm account removal."); + let toast = libadwaita::Toast::new(t("Type “remove” to confirm account removal.")); host_for_response.add_toast(toast); return; } From 40234a75c54af519a5e9527074ef9dca6eacd79a Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:04:08 +0200 Subject: [PATCH 11/14] fix(i18n): translate dynamic messages through the catalog placeholders t(&format!(...)) built a key that never matched a catalog entry, so the Remove, password-store and space labels always fell back to English even with translations present. The messages now use the static msgid with .replace() and the missing catalog entries are filled in. Closes #124 --- po/es.po | 6 +++++- src/ui/settings.rs | 3 +-- src/ui/setup.rs | 14 ++++++++++---- src/util/translations/es.rs | 2 ++ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/po/es.po b/po/es.po index 46819be..3db9830 100644 --- a/po/es.po +++ b/po/es.po @@ -1473,7 +1473,7 @@ msgstr "Continuar" #: src/nextsync/ui/settings.py:722 #, python-brace-format msgid "Remove {name}?" -msgstr "" +msgstr "¿Eliminar {name}?" #: src/nextsync/ui/settings.py:725 msgid "" @@ -2533,6 +2533,10 @@ msgstr "Inicia sesión autorizando la aplicación en tu navegador" msgid "Space" msgstr "Espacio" +#: src/ui/setup.rs +msgid "Space: {id}" +msgstr "Espacio: {id}" + #: src/ui/setup.rs msgid "Sync provider" msgstr "Proveedor de sincronización" diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 19b7f3b..1df9e63 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2117,11 +2117,10 @@ fn present_remove_account_step_two( callbacks: &SettingsCallbacks, ) { let dialog = libadwaita::AlertDialog::new( - Some(t(&format!("Remove {login_name}?"))), + Some(&t("Remove {name}?").replace("{name}", login_name)), 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”"))); let entry_box = gtk4::Box::new(gtk4::Orientation::Vertical, 8); entry_box.append(&entry); dialog.set_extra_child(Some(&entry_box)); diff --git a/src/ui/setup.rs b/src/ui/setup.rs index c7e93f1..e3e9801 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -783,9 +783,13 @@ fn manual_login(ctx: &SetupContext) { } Err(message) => { ctx_for_store.widgets.manual_button.set_sensitive(true); - ctx_for_store.widgets.auth_error.set_text(t(&format!( - "Could not store the account password: {message}" - ))); + ctx_for_store.widgets.auth_error.set_text( + &t("Could not store the account password: {message}").replacen( + "{message}", + &message, + 1, + ), + ); } } }); @@ -1004,7 +1008,9 @@ fn present_folder_error(ctx: &SetupContext, message: &str) { fn update_space_label(ctx: &SetupContext, space_id: Option<&str>) { match space_id { Some(id) => { - ctx.widgets.space_label.set_text(t(&format!("Space: {id}"))); + ctx.widgets + .space_label + .set_text(&t("Space: {id}").replace("{id}", id)); ctx.widgets.space_label.set_visible(true); } None => { diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index c3f31cf..c75c0ef 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -316,6 +316,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Remove synchronization", "Eliminar sincronización"), ("Remove the synchronization and move the local folder to the trash, or keep the folder on disk without synchronizing it.", "Quita la sincronización y mueve la carpeta local a la papelera, o conserva la carpeta en el disco sin sincronizarla."), ("Remove this folder from synchronization?", "¿Eliminar esta carpeta de la sincronización?"), + ("Remove {name}?", "¿Eliminar {name}?"), ("Removing the Files bookmark outside the application now updates Settings without recreating it.", "Al quitar el marcador desde Archivos, ahora se actualizan los Ajustes sin volver a crearlo."), ("Removing the account only removes the connection; your local folders and files are never touched.", "Eliminar la cuenta solo quita la conexión; tus carpetas y archivos locales nunca se ven afectados."), ("Replace the current configuration from a backup file", "Sustituye la configuración actual por la de un fichero de copia"), @@ -367,6 +368,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Signed in.", "Sesión iniciada."), ("Source code on GitHub", "Código fuente en GitHub"), ("Space", "Espacio"), + ("Space: {id}", "Espacio: {id}"), ("Start", "Empezar"), ("Start Fresh", "Empezar de cero"), ("Start NextSync when I sign in", "Iniciar NextSync al iniciar sesión"), From dcc934add9bf960ddbd2c6f1e9354ad91dd623e5 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 15:05:05 +0200 Subject: [PATCH 12/14] chore(release): bump version to 0.118.0 --- CHANGELOG.md | 15 +++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- PKGBUILD | 2 +- README.es.md | 2 +- README.md | 2 +- version.json | 8 ++++---- 7 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97f170..cdb3103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Todas las versiones notables de NextSync se documentan aquí. El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/) y el versionado es **+0.02 por release** (decisión del usuario, 15-Ago-2026). +## [0.118.0] - 2026-08-21 + +### Corregido +- **Conservar remoto borra la copia conflictiva (#120)**: al resolver un conflicto con "Conservar remoto", la copia `(Nextcloud conflicted copy …)` se eliminaba; antes quedaba en disco y el mismo conflicto reaparecía en cada sincronización. +- **Suscripciones de fila desuscritas al descartar la fila (#121)**: cada fila de carpeta libera sus callbacks de estado y progreso al salir del árbol; antes quedaban vivos capturando widgets, el mismo vector de crash que #102 más una fuga de listeners. +- **La ventana de conflictos cacheada se resetea al cerrar (#122)**: "Resolver conflictos" de otra carpeta ya no reabre la ventana de la carpeta anterior con su poll detenido. +- **Rutas WebDAV con escape de porcentaje (#123)**: carpetas con espacios o acentos ("Música Albums") generan URIs válidas en MKCOL/PROPFIND; el nombre de usuario también se escapa. +- **Traducción de mensajes dinámicos (#124)**: "Eliminar {name}?", "No se pudo guardar la contraseña…" y "Espacio: {id}" se traducen de verdad vía catálogo, no claves dinámicas que jamás matcheaban. +- **Icono de éxito en pestaña Recientes en español (#125)**: las líneas localizadas ("Sincronización completada") reciben el check, no el icono de información. +- **El secreto de la cuenta se limpia del registro del motor (#126)**: si el motor imprime la contraseña o token, se redacta en el buffer de salida y en el log. +- **Un trigger que llega durante el enfriamiento arranca al terminar (#127)**: "sincronizar ahora" durante los 4 s de enfriamiento ya no se queda varado hasta el siguiente trigger. +- **El selector de carpetas remotas filtra por el nombre, no el path completo (#128)**: un login que contenga "trash"/"versions" ya no oculta todas las carpetas. +- **El texto "Conectado" sigue al estado (#129)**: al caer la conexión el resumen cambia a "No conectado", no solo el icono. +- **Toast de confirmación de borrado traducido (#130)**: "Escribe «remove» para confirmar la eliminación de la cuenta." en español. + ## [0.116.0] - 2026-08-21 ### Corregido diff --git a/Cargo.lock b/Cargo.lock index f1b6103..24abdf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,7 +1230,7 @@ dependencies = [ [[package]] name = "nextsync" -version = "0.116.0" +version = "0.118.0" dependencies = [ "async-channel", "data-encoding", diff --git a/Cargo.toml b/Cargo.toml index 889e8a7..b15a695 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nextsync" -version = "0.116.0" +version = "0.118.0" edition = "2021" rust-version = "1.83" license = "GPL-3.0-or-later" diff --git a/PKGBUILD b/PKGBUILD index d4a56e1..957daca 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: gnacho pkgname=nextsync -pkgver=0.116.0 +pkgver=0.118.0 pkgrel=1 pkgdesc='Nextcloud desktop synchronization client for GNOME (Rust rewrite)' arch=('x86_64' 'aarch64') diff --git a/README.es.md b/README.es.md index 62b7512..bdbd1e3 100644 --- a/README.es.md +++ b/README.es.md @@ -12,7 +12,7 @@ English

- Versión 0.116.0 + Versión 0.118.0 Linux GNOME GTK 4 diff --git a/README.md b/README.md index 905c27d..ea7f6e7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Español

- Version 0.116.0 + Version 0.118.0 Linux GNOME GTK 4 diff --git a/version.json b/version.json index 40f70c0..160b679 100644 --- a/version.json +++ b/version.json @@ -1,10 +1,10 @@ { "schema_version": 1, - "version": "0.116.0", + "version": "0.118.0", "mandatory": false, - "summary": "Support --background so the autostart starts the app in the tray.", + "summary": "Audit batch: 7 bug fixes and 4 robustness improvements.", "changelog": [ - "The binary accepts --background and starts minimized to the tray, so the login autostart entry now works (issue #119)." + "keep_remote now deletes the conflicted copy (issues #120-#130)." ], - "released_at": "2026-08-21T05:00:00Z" + "released_at": "2026-08-21T00:00:00Z" } \ No newline at end of file From 10ed9b4f4e9a5d8581bc0cab74ae1a6463459bf4 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 16:46:47 +0200 Subject: [PATCH 13/14] fix(engine): advance the progress counter across non-transfer phases The propagation whitelist only counted DownloadFile/UploadFile/PropagateJob and PropagateDirectory, so during local/remote removes, renames/moves, mkdirs and encrypted transfers the per-file label and counter froze on the last parsed file even though the engine kept working. Every propagator completion now advances the counter, mapped to 'processing' for the phases that are not transfers. Closes #131 --- src/nextcloud/nextcloudcmd_progress.rs | 41 ++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/nextcloud/nextcloudcmd_progress.rs b/src/nextcloud/nextcloudcmd_progress.rs index e970dd8..e51526a 100644 --- a/src/nextcloud/nextcloudcmd_progress.rs +++ b/src/nextcloud/nextcloudcmd_progress.rs @@ -35,7 +35,7 @@ impl SyncProgress { pub fn is_operation(&self) -> bool { matches!( self.action.as_str(), - "download" | "upload" | "delete" | "conflict" | "checking" + "download" | "upload" | "delete" | "conflict" | "checking" | "processing" ) } @@ -125,6 +125,12 @@ fn strip_log_prefix(line: &str) -> &str { /// `Completed propagation of "x" by OCC::PropagateDownloadFile(...) with /// status OCC::SyncFileItem::Success`. +/// +/// Every propagator job counts (issue #131): download/upload carry their +/// action, directories/jobs read as discovery, and the remaining phases +/// (local/remote remove, rename/move, mkdir, encrypted, vfs, ignore) all +/// advance the counter as "processing" so the label does not freeze on the +/// last transfer while the engine works on those phases. fn parse_propagation_line(line: &str) -> Option { let rest = line.strip_prefix("Completed propagation of \"")?; let (path, tail) = rest.split_once('"')?; @@ -138,7 +144,7 @@ fn parse_propagation_line(line: &str) -> Option { } else if tail.contains("PropagateJob") || tail.contains("PropagateDirectory") { "checking" } else { - return None; + "processing" }; Some(SyncProgress::new(action, path.to_string())) } @@ -281,6 +287,37 @@ mod tests { assert_eq!(failed.action, "download"); } + #[test] + fn propagation_phases_without_transfer_advance_the_counter() { + // Issue #131: deletes, moves and mkdirs emit propagator lines the old + // whitelist dropped, freezing the label on the last transfer. Every + // propagator job now counts. + let removed = parse_progress_line( + "Completed propagation of \"old.pdf\" by OCC::PropagateLocalRemove(0x1) with status OCC::SyncFileItem::Success", + ) + .expect("a remove line should parse"); + assert_eq!(removed.action, "processing"); + assert_eq!(removed.path, "old.pdf"); + + let moved = parse_progress_line( + "Completed propagation of \"docs/report.txt\" by OCC::PropagateRemoteMove(0x1) with status OCC::SyncFileItem::Success", + ) + .expect("a move line should parse"); + assert_eq!(moved.action, "processing"); + + let mkdir = parse_progress_line( + "Completed propagation of \"new dir\" by OCC::PropagateLocalMkdir(0x1) with status OCC::SyncFileItem::Success", + ) + .expect("a mkdir line should parse"); + assert_eq!(mkdir.action, "processing"); + + let encrypted = parse_progress_line( + "Completed propagation of \"vault.pdf\" by OCC::PropagateUploadEncrypted(0x1) with status OCC::SyncFileItem::Success", + ) + .expect("an encrypted line should parse"); + assert_eq!(encrypted.action, "processing"); + } + #[test] fn parses_real_discovery_lines() { // The discovery phase: directories walked and items found. From c8cf31252145c0c29a682cb6f3007d792972100e Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 21 Aug 2026 16:58:37 +0200 Subject: [PATCH 14/14] style(engine): drop a variable-free format! in the redaction test --- src/nextcloud/sync_engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nextcloud/sync_engine.rs b/src/nextcloud/sync_engine.rs index 2af55ef..3504ac3 100644 --- a/src/nextcloud/sync_engine.rs +++ b/src/nextcloud/sync_engine.rs @@ -791,7 +791,7 @@ mod tests { let conflict_signal = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (progress_tx, progress_rx) = async_channel::unbounded(); let redactor = Arc::new(Redactor::from_secrets(["s3cr3t-t0ken-here"])); - let line = format!("Downloading using token s3cr3t-t0ken-here\n"); + let line = "Downloading using token s3cr3t-t0ken-here\n".to_string(); let handle = drain_stream( std::io::Cursor::new(line), progress_tx,