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
-
+
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/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,