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>, + 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..6b25152 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,7 @@ 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) { + pub(crate) fn present_delete_review(&self, account_id: &str, folder_id: &str) { let Some(account) = self .config .accounts diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index a4b22ca..532b91a 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -336,6 +336,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 +400,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"), From d769182e29ade240dd4e680189fc97fb465f4bd2 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 8 Sep 2026 17:13:03 +0200 Subject: [PATCH 2/5] chore(release): bump version to 0.2.18 --- CHANGELOG.md | 5 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- PKGBUILD | 2 +- README.es.md | 2 +- README.md | 2 +- data/io.github.gnacho.nextsync.metainfo.xml | 1 + landing/index.html | 2 +- version.json | 10 +++++----- 9 files changed, 17 insertions(+), 11 deletions(-) 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 4ae1893..8944b69 100644 --- a/README.es.md +++ b/README.es.md @@ -12,7 +12,7 @@ English

- Versión 0.2.16 + Versión 0.2.18 Sitio web Linux GNOME diff --git a/README.md b/README.md index 9a45e6d..f9285bc 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Español

- Version 0.2.16 + Version 0.2.18 Website Linux GNOME 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"])