From e8390b3063ba923410abbcaad33a04f09e2a00de Mon Sep 17 00:00:00 2001
From: gnacho
Date: Tue, 8 Sep 2026 16:54:00 +0200
Subject: [PATCH 1/5] feat(ui): notify the user of a pending mass deletion
review
---
po/es.po | 8 ++++
src/core/notifications.rs | 81 +++++++++++++++++++++++++++++++++++++
src/core/scheduler.rs | 55 ++++++++++++++++++++++++-
src/main.rs | 6 +++
src/ui/main_window.rs | 61 +++++++++++++++++++++++++++-
src/util/translations/es.rs | 2 +
6 files changed, 210 insertions(+), 3 deletions(-)
diff --git a/po/es.po b/po/es.po
index 4a44e18..25ae92a 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"
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
-
+
diff --git a/README.md b/README.md
index 9a45e6d..f9285bc 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
Español
-
+
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
Descarga el paquete .pkg.tar.zst más reciente desde GitHub Releases y ajusta el nombre del fichero.
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"
}
From 9e37dce01a7d3504db034d3c9fa89682dfca618e Mon Sep 17 00:00:00 2001
From: gnacho
Date: Tue, 8 Sep 2026 17:59:49 +0200
Subject: [PATCH 3/5] fix(ui): present the deletion review dialog when the app
is in the tray
---
src/ui/main_window.rs | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs
index 6b25152..8ff8f72 100644
--- a/src/ui/main_window.rs
+++ b/src/ui/main_window.rs
@@ -1296,6 +1296,26 @@ impl MainWindow {
/// Nextcloud re-downloads the folder; Approve These Deletions Once lets a
/// single run proceed. Nextcloud accounts additionally get the server
/// trash browser.
+ /// 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
@@ -1320,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();
@@ -1461,7 +1481,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.
From 703f2de79c7a76c9deddfa11ea1afa4716c30001 Mon Sep 17 00:00:00 2001
From: gnacho
Date: Tue, 8 Sep 2026 18:12:51 +0200
Subject: [PATCH 4/5] feat(ui): summarize mass deletions instead of listing
every file
---
po/es.po | 4 +
src/ui/main_window.rs | 152 +++++++++++++++++++++++-------------
src/util/translations/es.rs | 1 +
3 files changed, 101 insertions(+), 56 deletions(-)
diff --git a/po/es.po b/po/es.po
index 25ae92a..f04caba 100644
--- a/po/es.po
+++ b/po/es.po
@@ -1093,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/ui/main_window.rs b/src/ui/main_window.rs
index 8ff8f72..9e8fe46 100644
--- a/src/ui/main_window.rs
+++ b/src/ui/main_window.rs
@@ -1361,77 +1361,117 @@ 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);
+ }
+ note = 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;
}
}
+ 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()
+ };
}
- 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()
- };
let label = gtk4::Label::builder()
.label(¬e)
.css_classes(["dim-label", "caption"])
diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs
index 532b91a..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"),
From 88ed73c53772b03beb5f183d92bbd56846e6c413 Mon Sep 17 00:00:00 2001
From: gnacho
Date: Tue, 8 Sep 2026 18:20:46 +0200
Subject: [PATCH 5/5] refactor(ui): drop needless late init in the deletion
summary (clippy 1.98)
---
src/ui/main_window.rs | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs
index 9e8fe46..1949533 100644
--- a/src/ui/main_window.rs
+++ b/src/ui/main_window.rs
@@ -1367,8 +1367,7 @@ impl MainWindow {
.selection_mode(gtk4::SelectionMode::None)
.build();
let review_rows = crate::core::delete_guard::deletion_review_rows(&missing);
- let note;
- if missing_len > DELETION_LIST_MAX {
+ 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
@@ -1401,8 +1400,8 @@ impl MainWindow {
.build();
list.append(&loose_row);
}
- note = t("These deletions will be propagated to the server when it synchronizes.")
- .to_string();
+ 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;
@@ -1464,14 +1463,14 @@ impl MainWindow {
}
}
}
- note = if truncated {
+ 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)
.css_classes(["dim-label", "caption"])