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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.2.16"
version = "0.2.18"
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.2.16
pkgver=0.2.18
pkgrel=1
pkgdesc='Nextcloud desktop synchronization client for GNOME (Rust rewrite)'
arch=('x86_64' 'aarch64')
Expand Down
4 changes: 2 additions & 2 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</p>
<p>
<a href="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/gnacho/nextsync/actions/workflows/ci.yml"><img src="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/gnacho/nextsync/actions/workflows/ci.yml/badge.svg" alt="Estado de CI"></a>
<img src="https://img.shields.io/badge/version-0.2.16-blue?style=flat-square" alt="Versión 0.2.16">
<img src="https://img.shields.io/badge/version-0.2.18-blue?style=flat-square" alt="Versión 0.2.18">
<img src="https://img.shields.io/badge/license-GPL--3.0%2B-informational?style=flat-square" alt="GNU GPL v3 o posterior">
</p>
</div>
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</p>
<p>
<a href="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/gnacho/nextsync/actions/workflows/ci.yml"><img src="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/gnacho/nextsync/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
<img src="https://img.shields.io/badge/version-0.2.16-blue?style=flat-square" alt="Version 0.2.16">
<img src="https://img.shields.io/badge/version-0.2.18-blue?style=flat-square" alt="Version 0.2.18">
<img src="https://img.shields.io/badge/license-GPL--3.0%2B-informational?style=flat-square" alt="GNU GPL v3 or later">
</p>
</div>
Expand Down Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions data/io.github.gnacho.nextsync.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<icon type="stock">io.github.gnacho.nextsync</icon>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.2.18" date="2026-09-08"/>
<release version="0.2.16" date="2026-08-27"/>
<release version="0.2.14" date="2026-08-26"/>
<release version="0.2.12" date="2026-08-26"/>
Expand Down
2 changes: 1 addition & 1 deletion landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ <h3 data-i18n="compare.ht">Lo que NextSync no hace (todavía)</h3>
<h2 class="reveal" data-i18n="install.title">Instalación fácil y rápida</h2>
<p class="lead reveal" data-i18n="install.lead">En Arch, CachyOS y derivadas hay paquete listo en cada release.</p>
<div class="card install-card reveal">
<pre class="code-block"><code id="installCmd">sudo pacman -U nextsync-0.2.16-1-x86_64.pkg.tar.zst</code></pre>
<pre class="code-block"><code id="installCmd">sudo pacman -U nextsync-0.2.18-1-x86_64.pkg.tar.zst</code></pre>
<button class="btn suggested" id="copyBtn" data-i18n="install.copy">Copiar comando</button>
<p class="small-print" data-i18n="install.note">Descarga el paquete <code>.pkg.tar.zst</code> más reciente desde <a href="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/gnacho/nextsync/releases" target="_blank" rel="noopener">GitHub Releases</a> y ajusta el nombre del fichero.</p>
</div>
Expand Down
12 changes: 12 additions & 0 deletions po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
81 changes: 81 additions & 0 deletions src/core/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Fn(&str) + Send + 'static>,
);
}

/// Production notifier over org.freedesktop.Notifications (notify-rust).
Expand All @@ -29,18 +41,70 @@ impl DesktopNotifier for FreedesktopNotifier {
eprintln!("notification failed: {error}");
}
}

fn send_delete_review(
&self,
summary: &str,
body: &str,
on_action: Box<dyn Fn(&str) + Send + 'static>,
) {
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<u32>,
last_summary: Cell<Option<String>>,
last_body: Cell<Option<String>>,
}

impl CountingNotifier {
/// Summary of the most recent notification, if any.
pub fn last_summary(&self) -> Option<String> {
self.last_summary.take()
}

/// Body of the most recent notification, if any.
pub fn last_body(&self) -> Option<String> {
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<dyn Fn(&str) + Send + 'static>,
) {
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.
Expand Down Expand Up @@ -119,4 +183,21 @@ mod tests {
notify_for_outcome(&notifier, 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<dyn DesktopNotifier> = 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()),
);
}
}
55 changes: 53 additions & 2 deletions src/core/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ pub struct Scheduler {
/// Callback invoked once per finished run with its outcome.
type CompletedCallback = Box<dyn Fn(&SyncOutcome) + 'static>;

/// 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<dyn Fn(bool, Option<DeleteAlert>)>;

struct SchedulerInner {
state: StateController,
permit: Option<SyncPermit>,
Expand All @@ -145,6 +149,12 @@ struct SchedulerInner {
runner: Box<dyn SyncRunner>,
guard: Option<Box<dyn GuardCheck>>,
on_completed: Option<CompletedCallback>,
/// 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<DeleteReviewCallback>,
settings: TriggerSettings,
self_ref: Weak<RefCell<SchedulerInner>>,
online: bool,
Expand Down Expand Up @@ -235,6 +245,7 @@ impl Scheduler {
runner,
guard: None,
on_completed,
delete_review_cb: None,
settings,
self_ref: Weak::new(),
online: true,
Expand Down Expand Up @@ -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<DeleteReviewCallback>) {
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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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);
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading