Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Maintainer: gnacho <https://github.com/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')
Expand Down
2 changes: 1 addition & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="README.md">English</a>
</p>
<p>
<img src="https://img.shields.io/badge/version-0.116.0-6557e8?style=flat-square" alt="Versión 0.116.0">
<img src="https://img.shields.io/badge/version-0.118.0-6557e8?style=flat-square" alt="Versión 0.118.0">
<img src="https://img.shields.io/badge/platform-Linux-f0c674?style=flat-square&logo=linux&logoColor=111" alt="Linux">
<img src="https://img.shields.io/badge/desktop-GNOME-4a86cf?style=flat-square&logo=gnome&logoColor=white" alt="GNOME">
<img src="https://img.shields.io/badge/GTK-4-4a86cf?style=flat-square&logo=gtk&logoColor=white" alt="GTK 4">
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="README.es.md">Español</a>
</p>
<p>
<img src="https://img.shields.io/badge/version-0.116.0-6557e8?style=flat-square" alt="Version 0.116.0">
<img src="https://img.shields.io/badge/version-0.118.0-6557e8?style=flat-square" alt="Version 0.118.0">
<img src="https://img.shields.io/badge/platform-Linux-f0c674?style=flat-square&logo=linux&logoColor=111" alt="Linux">
<img src="https://img.shields.io/badge/desktop-GNOME-4a86cf?style=flat-square&logo=gnome&logoColor=white" alt="GNOME">
<img src="https://img.shields.io/badge/GTK-4-4a86cf?style=flat-square&logo=gtk&logoColor=white" alt="GTK 4">
Expand Down
6 changes: 5 additions & 1 deletion po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/core/conflict_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 ------------------------------------------------------------
Expand Down
32 changes: 27 additions & 5 deletions src/core/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 25 additions & 15 deletions src/nextcloud/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -428,7 +428,10 @@ impl NextcloudApi {
remote_path: &str,
) -> Result<bool, 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 folder_path = href_path_of(&folder).to_owned();
let entries = self.propfind(&folder, username, password)?;
let children = entries
Expand All @@ -454,7 +457,10 @@ impl NextcloudApi {
remote_path: &str,
) -> Result<Option<u64>, 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"),
Expand Down Expand Up @@ -535,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();
Expand Down Expand Up @@ -816,8 +824,9 @@ impl NextcloudApi {
password: &str,
) -> Result<Vec<TrashItem>, 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 = [
Expand Down Expand Up @@ -1034,8 +1043,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)
)
}

Expand Down
41 changes: 39 additions & 2 deletions src/nextcloud/nextcloudcmd_progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}

Expand Down Expand Up @@ -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<SyncProgress> {
let rest = line.strip_prefix("Completed propagation of \"")?;
let (path, tail) = rest.split_once('"')?;
Expand All @@ -138,7 +144,7 @@ fn parse_propagation_line(line: &str) -> Option<SyncProgress> {
} else if tail.contains("PropagateJob") || tail.contains("PropagateDirectory") {
"checking"
} else {
return None;
"processing"
};
Some(SyncProgress::new(action, path.to_string()))
}
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading