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
-
+
diff --git a/README.md b/README.md
index 905c27d..ea7f6e7 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
Español
-
+
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/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 ------------------------------------------------------------
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);
diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs
index 091dc27..fedaae0 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