diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1304e81..29cedef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
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.0.2 por release, reiniciado en 0.1.4** (decisión del usuario, 22-Ago-2026; sustituye al +0.02 anterior).
+## [0.2.18] - 2026-09-08
+
+### Añadido
+- **La app avisa de un borrado masivo pendiente de revisión (#203)**: cuando el guard de borrado detecta que un número anómalo de archivos ha desaparecido de la carpeta local y pausa la sincronización para protegerlos, ahora aparece una notificación de escritorio de prioridad urgente. La notificación explica que la sincronización se pausó antes de que esos archivos se eliminaran del servidor y ofrece un botón **Revisar ahora** que abre directamente el diálogo de revisión, aunque la app esté solo en la bandeja del sistema. Solo los borrados locales disparan esta revisión; los originados en el servidor, la web o el móvil los gestiona el motor y no requieren confirmación local.
+
## [0.2.16] - 2026-08-27
### Corregido
diff --git a/Cargo.lock b/Cargo.lock
index 7b67a46..3ef9461 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1283,7 +1283,7 @@ dependencies = [
[[package]]
name = "nextsync"
-version = "0.2.16"
+version = "0.2.18"
dependencies = [
"async-channel",
"data-encoding",
diff --git a/Cargo.toml b/Cargo.toml
index ee2f624..16944c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nextsync"
-version = "0.2.16"
+version = "0.2.18"
edition = "2021"
rust-version = "1.83"
license = "GPL-3.0-or-later"
diff --git a/PKGBUILD b/PKGBUILD
index 520f47f..a1aa77e 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: gnacho
pkgname=nextsync
-pkgver=0.2.16
+pkgver=0.2.18
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 6d64cbe..2e568b7 100644
--- a/README.es.md
+++ b/README.es.md
@@ -17,7 +17,7 @@
-
+
@@ -84,7 +84,7 @@ Ambos motores se esconden detrás del mismo trait pequeño, así que un proveedo
Descarga el `.pkg.tar.zst` de la [última release](https://github.com/gnacho/nextsync/releases/latest) e instálalo:
```bash
-sudo pacman -U nextsync-0.2.16-1-x86_64.pkg.tar.zst
+sudo pacman -U nextsync-0.2.18-1-x86_64.pkg.tar.zst
```
El paquete depende de `gtk4` y `libadwaita`. Para cuentas Nextcloud instala `nextcloud-client` (aporta `nextcloudcmd`); para cuentas OpenCloud, el `opencloudcmd` oficial.
diff --git a/README.md b/README.md
index d968f0e..070909f 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-
+
@@ -84,7 +84,7 @@ Both engines sit behind the same small trait, so a new provider is a command bui
Download the `.pkg.tar.zst` from the [latest release](https://github.com/gnacho/nextsync/releases/latest) and install it:
```bash
-sudo pacman -U nextsync-0.2.16-1-x86_64.pkg.tar.zst
+sudo pacman -U nextsync-0.2.18-1-x86_64.pkg.tar.zst
```
The package depends on `gtk4` and `libadwaita`. For Nextcloud accounts install `nextcloud-client` (it provides `nextcloudcmd`); for OpenCloud accounts, the official `opencloudcmd`.
diff --git a/data/io.github.gnacho.nextsync.metainfo.xml b/data/io.github.gnacho.nextsync.metainfo.xml
index 1925ab7..f3dafe0 100644
--- a/data/io.github.gnacho.nextsync.metainfo.xml
+++ b/data/io.github.gnacho.nextsync.metainfo.xml
@@ -32,6 +32,7 @@
io.github.gnacho.nextsync
+
diff --git a/landing/index.html b/landing/index.html
index b4e0edb..316b2bf 100644
--- a/landing/index.html
+++ b/landing/index.html
@@ -210,7 +210,7 @@ Lo que NextSync no hace (todavía)
Instalación fácil y rápida
En Arch, CachyOS y derivadas hay paquete listo en cada release.
-
sudo pacman -U nextsync-0.2.16-1-x86_64.pkg.tar.zst
+
sudo pacman -U nextsync-0.2.18-1-x86_64.pkg.tar.zst
Copiar comando
Descarga el paquete .pkg.tar.zst más reciente desde GitHub Releases y ajusta el nombre del fichero.
diff --git a/po/es.po b/po/es.po
index 4a44e18..f04caba 100644
--- a/po/es.po
+++ b/po/es.po
@@ -952,6 +952,14 @@ msgstr "Llavero de contraseñas bloqueado"
msgid "Review Deletions"
msgstr "Revisar borrados masivos"
+#: src/core/notifications.rs
+msgid "Review Now"
+msgstr "Revisar ahora"
+
+#: src/core/notifications.rs
+msgid "Synchronization was paused before {count} files could be deleted from Nextcloud."
+msgstr "La sincronización se pausó antes de que {count} archivos pudieran eliminarse de Nextcloud."
+
#: src/ui/folder_status.rs
msgid "Review deletions"
msgstr "Revisar borrados masivos"
@@ -1085,6 +1093,10 @@ msgstr "Preferencias"
msgid "About"
msgstr "Acerca de"
+#: src/ui/main_window.rs
+msgid "At the top level"
+msgstr "En el nivel raíz"
+
#: src/nextsync/ui/main_window.py:337 src/nextsync/ui/main_window.py:351
msgid "Accounts"
msgstr "Cuentas"
diff --git a/src/core/notifications.rs b/src/core/notifications.rs
index 32ef0c5..fcf2880 100644
--- a/src/core/notifications.rs
+++ b/src/core/notifications.rs
@@ -13,6 +13,18 @@ use std::rc::Rc;
pub trait DesktopNotifier {
/// Send a notification; `summary` is the title, `body` the detail.
fn send(&self, summary: &str, body: &str);
+
+ /// Raise a critical desktop notification for a pending deletion review
+ /// (issue #203). The notification explains synchronization was paused to
+ /// protect the missing files and carries a "Review Now" action. `on_action`
+ /// is fired on a worker thread with the action name (`"default"` for a body
+ /// click, or `"__closed"` when the notification is dismissed).
+ fn send_delete_review(
+ &self,
+ summary: &str,
+ body: &str,
+ on_action: Box,
+ );
}
/// Production notifier over org.freedesktop.Notifications (notify-rust).
@@ -29,18 +41,70 @@ impl DesktopNotifier for FreedesktopNotifier {
eprintln!("notification failed: {error}");
}
}
+
+ fn send_delete_review(
+ &self,
+ summary: &str,
+ body: &str,
+ on_action: Box,
+ ) {
+ let action_label = crate::util::i18n::t("Review Now").to_string();
+ let mut notification = notify_rust::Notification::new();
+ notification
+ .summary(summary)
+ .body(body)
+ .appname("nextsync")
+ .urgency(notify_rust::Urgency::Critical)
+ .action("default", &action_label);
+ match notification.show() {
+ Ok(handle) => {
+ // `wait_for_action` blocks a worker thread until the user acts
+ // on or dismisses the notification. Callers marshal back to the
+ // GLib main loop before touching UI.
+ std::thread::spawn(move || {
+ handle.wait_for_action(|action| on_action(action));
+ });
+ }
+ Err(error) => eprintln!("notification failed: {error}"),
+ }
+ }
}
/// Test notifier recording every send.
#[derive(Default)]
pub struct CountingNotifier {
pub sent: Cell,
+ last_summary: Cell>,
+ last_body: Cell >,
+}
+
+impl CountingNotifier {
+ /// Summary of the most recent notification, if any.
+ pub fn last_summary(&self) -> Option {
+ self.last_summary.take()
+ }
+
+ /// Body of the most recent notification, if any.
+ pub fn last_body(&self) -> Option {
+ self.last_body.take()
+ }
}
impl DesktopNotifier for CountingNotifier {
fn send(&self, _summary: &str, _body: &str) {
self.sent.set(self.sent.get() + 1);
}
+
+ fn send_delete_review(
+ &self,
+ summary: &str,
+ body: &str,
+ _on_action: Box,
+ ) {
+ self.sent.set(self.sent.get() + 1);
+ self.last_summary.set(Some(summary.to_string()));
+ self.last_body.set(Some(body.to_string()));
+ }
}
/// Notification copy for one outcome.
@@ -119,4 +183,21 @@ mod tests {
notify_for_outcome(¬ifier, false, "acct", &SyncOutcome::Failed);
assert_eq!(sent.sent.get(), 0);
}
+
+ #[test]
+ fn delete_review_notification_records_the_copy() {
+ let sent = Rc::new(CountingNotifier::default());
+ let notifier: Rc = sent.clone();
+ notifier.send_delete_review(
+ "Review Deletions",
+ "Synchronization was paused before 12 files could be deleted.",
+ Box::new(|_| {}),
+ );
+ assert_eq!(sent.sent.get(), 1);
+ assert_eq!(sent.last_summary(), Some("Review Deletions".to_string()),);
+ assert_eq!(
+ sent.last_body(),
+ Some("Synchronization was paused before 12 files could be deleted.".to_string()),
+ );
+ }
}
diff --git a/src/core/scheduler.rs b/src/core/scheduler.rs
index b0b2743..9930f47 100644
--- a/src/core/scheduler.rs
+++ b/src/core/scheduler.rs
@@ -136,6 +136,10 @@ pub struct Scheduler {
/// Callback invoked once per finished run with its outcome.
type CompletedCallback = Box;
+/// Callback fired when the deletion guard raises (`true`) or clears (`false`)
+/// a review, carrying the alert when raised. See `Scheduler::set_on_delete_review`.
+type DeleteReviewCallback = Rc)>;
+
struct SchedulerInner {
state: StateController,
permit: Option,
@@ -145,6 +149,12 @@ struct SchedulerInner {
runner: Box,
guard: Option>,
on_completed: Option,
+ /// Fired with `true` and the alert when the deletion guard raises a review
+ /// and `false` (with `None`) when it clears (approve/restore/stop). Used to
+ /// raise a proactive desktop notification (issue #203). Runs wherever the
+ /// inner is borrowed, so it must never call back into the scheduler; the
+ /// alert is cloned into the callback for that reason.
+ delete_review_cb: Option,
settings: TriggerSettings,
self_ref: Weak>,
online: bool,
@@ -235,6 +245,7 @@ impl Scheduler {
runner,
guard: None,
on_completed,
+ delete_review_cb: None,
settings,
self_ref: Weak::new(),
online: true,
@@ -390,6 +401,12 @@ impl Scheduler {
self.inner.borrow_mut().on_completed = on_completed;
}
+ /// Register a callback fired when the deletion guard raises (`true`) or
+ /// clears (`false`) a review. See `SchedulerInner::delete_review_cb`.
+ pub fn set_on_delete_review(&self, cb: Option) {
+ self.inner.borrow_mut().delete_review_cb = cb;
+ }
+
/// Approve one synchronization despite a deletion alert.
pub fn approve_delete_once(&self) {
self.inner.borrow_mut().approve_delete_once();
@@ -1195,10 +1212,17 @@ impl SchedulerInner {
}
}
+ fn notify_delete_review(&self, raised: bool, alert: Option<&DeleteAlert>) {
+ if let Some(cb) = &self.delete_review_cb {
+ cb(raised, alert.cloned());
+ }
+ }
+
fn set_delete_alert(&mut self, alert: DeleteAlert) {
self.state
.set(AppState::DeleteReview, alert.message.clone());
- self.delete_alert = Some(alert);
+ self.delete_alert = Some(alert.clone());
+ self.notify_delete_review(true, Some(&alert));
}
fn approve_delete_once(&mut self) {
@@ -1210,6 +1234,7 @@ impl SchedulerInner {
}
self.delete_alert = None;
self.delete_bypass_once = true;
+ self.notify_delete_review(false, None);
self.request(Trigger::Manual);
}
@@ -1236,6 +1261,7 @@ impl SchedulerInner {
}
self.delete_alert = None;
self.delete_bypass_once = false;
+ self.notify_delete_review(false, None);
self.request(Trigger::Manual);
}
@@ -1245,6 +1271,7 @@ impl SchedulerInner {
}
self.delete_alert = None;
self.delete_bypass_once = false;
+ self.notify_delete_review(false, None);
self.state
.set(AppState::IdleNotSynced, t("Not synchronized yet"));
self.request(Trigger::Manual);
@@ -1259,7 +1286,10 @@ impl SchedulerInner {
self.queue.clear();
self.local_dirty = false;
self.remote_pending = false;
- self.delete_alert = None;
+ if self.delete_alert.is_some() {
+ self.delete_alert = None;
+ self.notify_delete_review(false, None);
+ }
if self.running {
self.runner.cancel();
}
@@ -2073,6 +2103,27 @@ mod tests {
assert_eq!(runner.0.borrow().start_calls, 1);
}
+ #[test]
+ fn delete_review_callback_fires_raised_then_cleared() {
+ // Issue #203: the proactive notification hook fires `true` with the
+ // alert when the guard raises a review and `false` when it clears.
+ let (scheduler, _source, _runner) = make_scheduler(None);
+ let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(bool, bool)>::new()));
+ let cb_events = events.clone();
+ scheduler.set_on_delete_review(Some(Rc::new(move |raised, alert| {
+ cb_events.borrow_mut().push((raised, alert.is_some()));
+ })));
+ scheduler.set_delete_alert(DeleteAlert {
+ reason: "mass_local_deletion".to_string(),
+ message: "Many files were removed".to_string(),
+ can_approve_once: true,
+ ..DeleteAlert::default()
+ });
+ assert_eq!(*events.borrow(), vec![(true, true)]);
+ scheduler.clear_delete_alert();
+ assert_eq!(*events.borrow(), vec![(true, true), (false, false)]);
+ }
+
#[test]
fn pause_and_delete_alert_getters_reflect_the_inner_state() {
let (scheduler, _, _) = make_scheduler(None);
diff --git a/src/main.rs b/src/main.rs
index 1262a56..2d17457 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -238,6 +238,12 @@ fn main() {
}));
}
*window_slot.borrow_mut() = Some(main_window.clone());
+ // Proactive desktop notification for the deletion guard (issue
+ // #203): raises a critical notification when a folder's guard flags
+ // a mass deletion and routes "Review Now" back to this window.
+ main_window
+ .borrow()
+ .install_delete_review_handler(notifier.clone(), Rc::downgrade(&main_window));
// Register the tray (best effort; the app works without one).
let tray_callbacks = TrayCallbacks {
diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs
index 85ac9d2..1949533 100644
--- a/src/ui/main_window.rs
+++ b/src/ui/main_window.rs
@@ -868,6 +868,65 @@ impl MainWindow {
}));
}
+ /// Wire a proactive desktop notification for the deletion guard (issue
+ /// #203). `notifier` raises a critical notification when a folder's guard
+ /// flags a mass deletion; clicking "Review Now" (or the body) routes back
+ /// to this window on the main loop to present the deletion review, even
+ /// when the app runs only in the tray (background).
+ pub fn install_delete_review_handler(
+ &self,
+ notifier: Rc,
+ window_weak: Weak>,
+ ) {
+ // "Review Now" is fired on the notifier's worker thread, which cannot
+ // carry a non-`Send` `Weak` to this window. Route it back through an
+ // `mpsc` channel and a periodic poller on the main loop.
+ let (tx, rx) = std::sync::mpsc::channel::<(String, String)>();
+ let poll_weak = window_weak.clone();
+ let _poll = glib::timeout_add_local(std::time::Duration::from_millis(150), move || {
+ if let Ok((account_id, folder_id)) = rx.try_recv() {
+ if let Some(main) = poll_weak.upgrade() {
+ main.borrow().present_delete_review(&account_id, &folder_id);
+ }
+ }
+ glib::ControlFlow::Continue
+ });
+ for (account_id, runtime) in self.account_manager.runtimes() {
+ for (folder_id, folder) in runtime.folders() {
+ let notifier = notifier.clone();
+ let account_id = account_id.clone();
+ let folder_id = folder_id.clone();
+ let review_tx = tx.clone();
+ let cb: Rc)> = Rc::new(
+ move |raised, alert| {
+ if !raised {
+ return;
+ }
+ let Some(alert) = alert else {
+ return;
+ };
+ let count = alert.missing_paths.len();
+ let summary = t("Review Deletions").to_string();
+ let body = t(
+ "Synchronization was paused before {count} files could be deleted from Nextcloud.",
+ )
+ .replace("{count}", &count.to_string());
+ let on_review: Box = Box::new({
+ let review_tx = review_tx.clone();
+ let account_id = account_id.clone();
+ let folder_id = folder_id.clone();
+ move |_action| {
+ let _ = review_tx.send((account_id.clone(), folder_id.clone()));
+ }
+ });
+ notifier.send_delete_review(&summary, &body, on_review);
+ },
+ );
+ folder.scheduler().set_on_delete_review(Some(cb));
+ }
+ }
+ }
+
/// Open (or bring to front) the account setup wizard.
/// Open (or bring to front) the activity/conflicts window for the active
/// account's first synchronized folder.
@@ -1237,7 +1296,27 @@ impl MainWindow {
/// Nextcloud re-downloads the folder; Approve These Deletions Once lets a
/// single run proceed. Nextcloud accounts additionally get the server
/// trash browser.
- fn present_delete_review(&self, account_id: &str, folder_id: &str) {
+ /// Present an `AlertDialog` transient for `window`, making sure the window
+ /// is presented/mapped first. When the app runs only in the tray the main
+ /// window is hidden (not mapped), and a dialog with `set_transient_for` a
+ /// non-mapped parent is not shown by GTK; deferring the dialog by one idle
+ /// lets the window map first (issue #203, upstream release 0.1.32).
+ fn present_modal_dialog(
+ dialog: &libadwaita::AlertDialog,
+ window: &libadwaita::ApplicationWindow,
+ ) {
+ if !window.is_visible() {
+ window.present();
+ }
+ let dialog = dialog.clone();
+ let window = window.clone();
+ glib::idle_add_local_once(move || {
+ window.present();
+ dialog.present(Some(window.upcast_ref::()));
+ });
+ }
+
+ pub(crate) fn present_delete_review(&self, account_id: &str, folder_id: &str) {
let Some(account) = self
.config
.accounts
@@ -1261,7 +1340,7 @@ impl MainWindow {
Some(t("No deletions are pending review.")),
);
dialog.add_response("close", t("Close"));
- dialog.present(Some(self.window.upcast_ref::()));
+ MainWindow::present_modal_dialog(&dialog, &self.window);
return;
};
let missing = alert.missing_paths.clone();
@@ -1282,76 +1361,115 @@ impl MainWindow {
// mass cleanup (a removed SDK, virtualenv or build cache) shows a
// handful of expandable groups instead of a wall of paths.
if !missing.is_empty() {
- const GROUP_ROW_CAP: usize = 100;
- const GROUP_CHILD_CAP: usize = 25;
- const TOTAL_CHILD_CAP: usize = 200;
+ const DELETION_LIST_MAX: usize = 50;
let list = gtk4::ListBox::builder()
.css_classes(["boxed-list"])
.selection_mode(gtk4::SelectionMode::None)
.build();
let review_rows = crate::core::delete_guard::deletion_review_rows(&missing);
- let mut shown_rows = 0usize;
- let mut shown_children = 0usize;
- let mut truncated = false;
- for review_row in &review_rows {
- if shown_rows >= GROUP_ROW_CAP {
- truncated = true;
- break;
+ let note = if missing_len > DELETION_LIST_MAX {
+ // Summary mode (issue #203 UX feedback): a mass deletion hides
+ // the per-file wall. Show folder-level counters only (the alert
+ // body already carries the exact total), so several hundred
+ // flat files stay readable instead of a 200-row list.
+ let mut loose_count = 0usize;
+ for row in &review_rows {
+ match row {
+ crate::core::delete_guard::DeletionReviewRow::Group {
+ prefix,
+ count,
+ ..
+ } => {
+ let group_row = libadwaita::ExpanderRow::builder()
+ .title(prefix)
+ .subtitle(t("{count} files").replace("{count}", &count.to_string()))
+ .build();
+ group_row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
+ group_row.set_enable_expansion(false);
+ list.append(&group_row);
+ }
+ crate::core::delete_guard::DeletionReviewRow::File(_) => loose_count += 1,
+ }
}
- match review_row {
- crate::core::delete_guard::DeletionReviewRow::Group {
- prefix,
- count,
- paths,
- } => {
- let group_row = libadwaita::ExpanderRow::builder()
- .title(prefix)
- .subtitle(t("{count} files").replace("{count}", &count.to_string()))
- .build();
- group_row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
- for path in paths.iter().take(GROUP_CHILD_CAP) {
- if shown_children >= TOTAL_CHILD_CAP {
- truncated = true;
- break;
- }
- let child = libadwaita::ActionRow::builder()
- .title(path)
- .activatable(false)
- .selectable(false)
+ if loose_count > 0 {
+ let loose_row = libadwaita::ActionRow::builder()
+ .title(t("At the top level"))
+ .subtitle(t("{count} files").replace("{count}", &loose_count.to_string()))
+ .activatable(false)
+ .selectable(false)
+ .build();
+ list.append(&loose_row);
+ }
+ t("These deletions will be propagated to the server when it synchronizes.")
+ .to_string()
+ } else {
+ const GROUP_ROW_CAP: usize = 100;
+ const GROUP_CHILD_CAP: usize = 25;
+ const TOTAL_CHILD_CAP: usize = 200;
+ let mut shown_rows = 0usize;
+ let mut shown_children = 0usize;
+ let mut truncated = false;
+ for review_row in &review_rows {
+ if shown_rows >= GROUP_ROW_CAP {
+ truncated = true;
+ break;
+ }
+ match review_row {
+ crate::core::delete_guard::DeletionReviewRow::Group {
+ prefix,
+ count,
+ paths,
+ } => {
+ let group_row = libadwaita::ExpanderRow::builder()
+ .title(prefix)
+ .subtitle(t("{count} files").replace("{count}", &count.to_string()))
.build();
- group_row.add_row(&child);
- shown_children += 1;
+ group_row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
+ for path in paths.iter().take(GROUP_CHILD_CAP) {
+ if shown_children >= TOTAL_CHILD_CAP {
+ truncated = true;
+ break;
+ }
+ let child = libadwaita::ActionRow::builder()
+ .title(path)
+ .activatable(false)
+ .selectable(false)
+ .build();
+ group_row.add_row(&child);
+ shown_children += 1;
+ }
+ if paths.len() > GROUP_CHILD_CAP {
+ let more = libadwaita::ActionRow::builder()
+ .title(t("{count} more…").replace(
+ "{count}",
+ &(paths.len() - GROUP_CHILD_CAP).to_string(),
+ ))
+ .activatable(false)
+ .selectable(false)
+ .build();
+ group_row.add_row(&more);
+ }
+ list.append(&group_row);
+ shown_rows += 1;
}
- if paths.len() > GROUP_CHILD_CAP {
- let more = libadwaita::ActionRow::builder()
- .title(t("{count} more…").replace(
- "{count}",
- &(paths.len() - GROUP_CHILD_CAP).to_string(),
- ))
+ crate::core::delete_guard::DeletionReviewRow::File(path) => {
+ let row = libadwaita::ActionRow::builder()
+ .title(path)
.activatable(false)
.selectable(false)
.build();
- group_row.add_row(&more);
+ list.append(&row);
+ shown_rows += 1;
}
- list.append(&group_row);
- shown_rows += 1;
- }
- crate::core::delete_guard::DeletionReviewRow::File(path) => {
- let row = libadwaita::ActionRow::builder()
- .title(path)
- .activatable(false)
- .selectable(false)
- .build();
- list.append(&row);
- shown_rows += 1;
}
}
- }
- let note = if truncated {
- t("{count} more…").replace("{count}", &(review_rows.len() - shown_rows).to_string())
- } else {
- t("These deletions will be propagated to the server when it synchronizes.")
- .to_string()
+ if truncated {
+ t("{count} more…")
+ .replace("{count}", &(review_rows.len() - shown_rows).to_string())
+ } else {
+ t("These deletions will be propagated to the server when it synchronizes.")
+ .to_string()
+ }
};
let label = gtk4::Label::builder()
.label(¬e)
@@ -1402,7 +1520,7 @@ impl MainWindow {
),
_ => {}
});
- dialog.present(Some(self.window.upcast_ref::()));
+ MainWindow::present_modal_dialog(&dialog, &self.window);
}
/// Build the Settings callbacks against this window's shared cell.
diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs
index a4b22ca..29be120 100644
--- a/src/util/translations/es.rs
+++ b/src/util/translations/es.rs
@@ -43,6 +43,7 @@ pub static CATALOG: &[(&str, &str)] = &[
("App Token", "Token de aplicación"),
("Approve These Deletions Once", "Aprobar estos borrados masivos una vez"),
("Ask before syncing folders larger than", "Preguntar antes de sincronizar carpetas mayores de"),
+ ("At the top level", "En el nivel raíz"),
("Authentication", "Autenticación"),
("Authentication and synchronization failures", "Fallo de autenticación y sincronización"),
("Auto-scroll", "Desplazamiento automático"),
@@ -336,6 +337,7 @@ pub static CATALOG: &[(&str, &str)] = &[
("Resume sync", "Reanudar sincronización"),
("Retained the previous last-known-good safety baseline after interrupted runs.", "Se conservó la última base de seguridad válida tras ejecuciones interrumpidas."),
("Review Deletions", "Revisar borrados masivos"),
+ ("Review Now", "Revisar ahora"),
("Review Setup", "Revisar configuración"),
("Review after this many missing files", "Revisar tras este número de archivos desaparecidos"),
("Review after this percentage is missing", "Revisar tras este porcentaje de desaparición"),
@@ -399,6 +401,7 @@ pub static CATALOG: &[(&str, &str)] = &[
("Synchronization failed: credentials rejected", "Sincronización fallida: credenciales rechazadas"),
("Synchronization is paused", "La sincronización está en pausa"),
("Synchronization is paused on battery", "La sincronización está en pausa por batería"),
+ ("Synchronization was paused before {count} files could be deleted from Nextcloud.", "La sincronización se pausó antes de que {count} archivos pudieran eliminarse de Nextcloud."),
("Synchronize this account now", "Sincroniza esta cuenta ahora"),
("Synchronized", "Sincronizado"),
("Synchronized with conflicts — review the log", "Sincronizado con conflictos: revisa el registro"),
diff --git a/version.json b/version.json
index 6592ccb..2d37976 100644
--- a/version.json
+++ b/version.json
@@ -1,11 +1,11 @@
{
"schema_version": 1,
- "version": "0.2.16",
+ "version": "0.2.18",
"mandatory": false,
- "summary": "Fixed a race where a waiting folder swallowed the sync turn, leaving the tray icon stuck on syncing forever.",
+ "summary": "Added a proactive desktop notification when the deletion guard pauses synchronization for a mass local deletion.",
"changelog": [
- "When the shared sync permit woke a waiting folder that then could not start (for example a leftover waiter with an empty queue), the turn was swallowed and every other waiting folder stayed queued forever with nothing running - the tray icon stayed on cloud-sync. The turn is now passed on to the next waiter (issue #197).",
- "This became visible with the ETag gate making runs milliseconds long; the regression test covers the exact double-waiter interleaving."
+ "The deletion guard now raises a critical desktop notification when it detects an abnormal number of missing local files and pauses synchronization to protect them before they can be deleted from Nextcloud. The notification explains the pause and offers a Review Now button that opens the deletion review directly, even when the app is running only in the tray.",
+ "Deletions originating from the server, the web interface or a mobile client continue to be handled by the sync engine and never require local confirmation."
],
- "released_at": "2026-08-27T00:00:00Z"
+ "released_at": "2026-09-08T00:00:00Z"
}