From f397d53b4e3c4bc859637ffdcc6814f123dd3eec Mon Sep 17 00:00:00 2001 From: Nacho Date: Fri, 21 Aug 2026 20:29:46 +0200 Subject: [PATCH 1/4] fix(progress): seed run_active flag when connecting during a run connect_progress() wires the progress forwarder after start() has already fired startup syncs. The run_active flag introduced in #145 starts false, so every event from the in-flight startup run was dropped and the folder row showed no per-file progress or bar movement. Seed the flag from scheduler.is_running() when connecting, so an in-flight run keeps emitting events. Later runs are still gated by prepare_sync()/finished() as before. Closes #148 --- src/core/account_runtime.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/account_runtime.rs b/src/core/account_runtime.rs index 1b8ccbc..bf73ae6 100644 --- a/src/core/account_runtime.rs +++ b/src/core/account_runtime.rs @@ -330,6 +330,13 @@ impl FolderRuntime { // cannot repaint the row. let run_active = std::rc::Rc::new(std::cell::Cell::new(false)); self.scheduler.set_run_active(run_active.clone()); + // Issue #148: startup syncs begin before connect_progress() wires the + // forwarder, so the flag would stay false for the entire first run and + // the UI would show no per-file progress. Seed it from the scheduler + // state when connecting, so an in-flight run keeps emitting events. + if self.scheduler.is_running() { + run_active.set(true); + } glib::spawn_future_local(async move { while let Ok(progress) = progress_rx.recv().await { if !run_active.get() { From 756c353d2d860aed42e31b3e6d5e47cc81067ccb Mon Sep 17 00:00:00 2001 From: Nacho Date: Fri, 21 Aug 2026 20:47:46 +0200 Subject: [PATCH 2/4] fix(ui): force folder row redraw after state/progress updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTK may not repaint the row when properties are updated from a subscription callback, so after a sync finishes the row could still show "Synchronizing…" with the old file caption until the user clicked elsewhere to force a redraw. Queue an explicit draw on the whole slot after every state render and on the progress label after every progress change, so the icon, spinner, run bar, subtitle and caption all update together without user interaction. Closes #150 --- src/ui/folder_status.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui/folder_status.rs b/src/ui/folder_status.rs index 2bc415d..0fd967e 100644 --- a/src/ui/folder_status.rs +++ b/src/ui/folder_status.rs @@ -396,6 +396,7 @@ impl FolderStatusRow { let spinner = this.spinner.clone(); let progress_bar = this.progress_bar.clone(); let row = this.row.clone(); + let slot = this.slot.clone(); let remote_path = this.remote_path.clone(); let format_last_sync = this.format_last_sync.clone(); let subscription = controller.subscribe(move |snapshot: &StateSnapshot| { @@ -408,6 +409,11 @@ impl FolderStatusRow { format_last_sync.as_ref().map(|f| f()), snapshot, ); + // Issue #150: GTK may not repaint the row when properties + // are updated from a subscription callback. Force a redraw + // of the whole slot so state, spinner, bar and caption all + // update together. + slot.queue_draw(); }); this._subscription = Some(subscription); // Live per-file progress (issue #86). Only the widgets are @@ -422,8 +428,12 @@ impl FolderStatusRow { .unwrap_or_else(|| progress.path.clone()); progress_label.set_text(&progress_line_text(progress, &file)); progress_label.set_visible(true); + progress_label.queue_draw(); + } + _ => { + progress_label.set_visible(false); + progress_label.queue_draw(); } - _ => progress_label.set_visible(false), }); this._progress_subscription = Some(progress_subscription); } From bb58ca8d33a961be72464d4c78de1006c5520d98 Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 22 Aug 2026 13:56:07 +0200 Subject: [PATCH 3/4] fix(ui): keep folder rows alive for the whole view lifetime AccountView::new built each FolderStatusRow, appended its slot and dropped the row object at the end of the loop. Since the fix for #121, dropping a row unsubscribes its live state and progress callbacks, so every row froze at the state it had when the view was built: no spinner, no run bar, no per-file progress and no queued state until the user switched accounts and back, which rebuilt the view. The view now owns its rows, so the subscriptions stay active while the rows are on screen and are still released when the view is rebuilt. Includes a regression test that flips a folder state and asserts the row repaints without a rebuild. Closes #151 --- src/ui/main_window.rs | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 3cbf848..810fa4c 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -135,6 +135,11 @@ pub struct AccountView { /// Per-folder state subscriptions (used-space refreshes after each /// completed synchronization, issue #43). _folder_subscriptions: Vec, + /// The folder rows, owned for the whole lifetime of the view (issue + /// #151): dropping a FolderStatusRow unsubscribes its live state and + /// progress callbacks (issue #121), so the rows must live exactly as + /// long as they are on screen or they freeze at their initial state. + _folder_rows: Vec, } impl AccountView { @@ -281,6 +286,9 @@ impl AccountView { let pairs = pair_folder_runtimes(&account.folders, runtime.folders()); let mut folder_subscriptions: Vec = Vec::new(); + // The view owns its rows (issue #151): dropping one unsubscribes its + // live callbacks, so they stay alive until the whole view is rebuilt. + let mut folder_rows: Vec = Vec::new(); for (folder, folder_runtime) in pairs { let local_root = folder.local_root.clone(); let account_id = account.id.clone(); @@ -438,6 +446,7 @@ impl AccountView { folder_subscriptions.push(subscription); } account_list.append(&row.slot); + folder_rows.push(row); } if account.folders.is_empty() { let row = libadwaita::ActionRow::builder() @@ -472,6 +481,7 @@ impl AccountView { _account_runtime: account_runtime, _subscription: Some(subscription), _folder_subscriptions: folder_subscriptions, + _folder_rows: folder_rows, } } } @@ -2542,6 +2552,89 @@ mod tests { }); } + /// Regression test for issue #151: the view must own its FolderStatusRow + /// objects for as long as they are on screen. Dropping a row unsubscribes + /// its live state/progress callbacks (the Drop impl from issue #121), so + /// a view that does not retain its rows freezes every row at the state it + /// had when the view was built: no spinner, no run bar, no live file + /// progress, no queued state until the whole view is rebuilt. + #[test] + fn folder_rows_follow_live_state_changes() { + crate::ui::test_helpers::gtk_smoke(|| { + set_locale(Locale::English); + let dir = tempfile::tempdir().unwrap(); + let mut account = window_account(); + account.folders = vec![crate::storage::config::FolderConfig { + id: "f1".to_string(), + local_root: dir.path().join("docs").to_string_lossy().into_owned(), + remote_path: "/docs".to_string(), + space_id: None, + size_confirmed: false, + }]; + let mut runtime = AccountRuntime::new( + account, + crate::storage::config::NetworkConfig::default(), + std::rc::Rc::new(std::cell::RefCell::new( + crate::core::debounce::FakeTimeoutSource::default(), + )), + None, + false, + ); + runtime.start_without_watchers(); + let callbacks = AccountCallbacks { + on_open_folder: None, + on_remove_folder: None, + on_edit_ignored: None, + on_add_folder: None, + on_pending_changes: None, + on_review_deletions: None, + on_resolve_conflicts: None, + on_avatar_cached: None, + }; + let view = AccountView::new( + runtime.clone(), + callbacks, + crate::core::log::LogBuffer::new(), + ); + + // Locate the folder row: ListBox > ListBoxRow > slot > ActionRow. + let mut sibling = view.root.first_child(); + let mut list = None; + while let Some(widget) = sibling { + if let Ok(candidate) = widget.clone().downcast::() { + list = Some(candidate); + break; + } + sibling = widget.next_sibling(); + } + let list = list.expect("folder list present"); + let list_row = list.row_at_index(0).expect("one folder row"); + let slot = list_row + .child() + .and_then(|widget| widget.downcast::().ok()) + .expect("row slot"); + let action_row = slot + .first_child() + .and_then(|widget| widget.downcast::().ok()) + .expect("folder action row"); + assert_eq!( + action_row.subtitle().as_deref(), + Some("Not Synchronized Yet") + ); + + // A live state change must repaint the row without a view rebuild. + let state = runtime.folders().get("f1").expect("folder runtime").state(); + state.set(crate::state::AppState::Syncing, ""); + assert_eq!(action_row.subtitle().as_deref(), Some("Synchronizing…")); + state.set(crate::state::AppState::IdleOk, ""); + assert_eq!( + action_row.subtitle().as_deref(), + Some("Synced in local docs") + ); + reset_locale(); + }); + } + #[test] fn account_settings_toggle_reveals_the_panel() { // The Account settings row must open the panel when activated From e097383601a01ff51fd89ff0bb572d5b064aaecb Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 22 Aug 2026 13:56:07 +0200 Subject: [PATCH 4/4] fix(ui): rename the queued state label to Waiting to synchronize 'Synchronization Scheduled' read like a future calendar event; the folder is actually queued behind the running one. Reuses the existing catalog entry and updates the Spanish translation to 'A la espera'. Refs #151 --- po/es.po | 10 +--------- src/core/scheduler.rs | 2 +- src/ui/folder_status.rs | 4 ++-- src/ui/tray_state.rs | 4 ++-- src/util/translations/es.rs | 4 +--- 5 files changed, 7 insertions(+), 17 deletions(-) diff --git a/po/es.po b/po/es.po index 6dd6313..570be9f 100644 --- a/po/es.po +++ b/po/es.po @@ -920,10 +920,6 @@ msgstr "Sincronizado" msgid "Automatic Sync Is Off" msgstr "Sincronización automática desactivada" -#: src/nextsync/ui/folder_status.py:21 -msgid "Synchronization Scheduled" -msgstr "Sincronización programada" - #: src/nextsync/ui/folder_status.py:22 msgid "Synchronizing…" msgstr "Sincronizando…" @@ -2574,10 +2570,6 @@ msgstr "En pausa por batería" msgid "Synchronization is paused" msgstr "La sincronización está en pausa" -#: src/core/scheduler.rs -msgid "Synchronization scheduled" -msgstr "Sincronización programada" - #: src/core/scheduler.rs msgid "Synchronized with conflicts — review the log" msgstr "Sincronizado con conflictos: revisa el registro" @@ -2682,7 +2674,7 @@ msgid "Synced in local {folder}" msgstr "Sincronizado en local {folder}" msgid "Waiting to synchronize" -msgstr "Esperando para sincronizar" +msgstr "A la espera" msgid "checking {file}" msgstr "comprobando {file}" diff --git a/src/core/scheduler.rs b/src/core/scheduler.rs index e1e679b..817f55c 100644 --- a/src/core/scheduler.rs +++ b/src/core/scheduler.rs @@ -501,7 +501,7 @@ impl SchedulerInner { return; } self.state - .set(AppState::SyncQueued, t("Synchronization scheduled")); + .set(AppState::SyncQueued, t("Waiting to synchronize")); let weak = self.self_ref.clone(); let id = self.source.borrow_mut().add_idle(Box::new(move || { if let Some(inner) = weak.upgrade() { diff --git a/src/ui/folder_status.rs b/src/ui/folder_status.rs index 0fd967e..7451cbd 100644 --- a/src/ui/folder_status.rs +++ b/src/ui/folder_status.rs @@ -29,7 +29,7 @@ pub fn folder_status_presentation(state: AppState) -> (&'static str, &'static st AppState::IdleOk => ("nextsync-row-ok", t("Synchronized")), AppState::IdleManualOnly => ("nextsync-row-paused", t("Automatic Sync Is Off")), AppState::IdleNotSynced => ("nextsync-row-paused", t("Not Synchronized Yet")), - AppState::SyncQueued => ("nextsync-row-paused", t("Synchronization Scheduled")), + AppState::SyncQueued => ("nextsync-row-paused", t("Waiting to synchronize")), AppState::Syncing => ("nextsync-row-syncing", t("Synchronizing…")), AppState::PausedUser => ("nextsync-row-paused", t("Paused")), AppState::PausedBattery => ("nextsync-row-battery", t("Paused on Battery")), @@ -755,7 +755,7 @@ mod tests { state.set(AppState::SyncQueued, "queued"); assert_eq!( row.row.subtitle().as_deref(), - Some("Synchronization Scheduled") + Some("Waiting to synchronize") ); state.set(AppState::IdleOk, "ok"); assert_eq!(row.row.subtitle().as_deref(), Some("Synced in local docs")); diff --git a/src/ui/tray_state.rs b/src/ui/tray_state.rs index 4875e2d..ceac34c 100644 --- a/src/ui/tray_state.rs +++ b/src/ui/tray_state.rs @@ -75,7 +75,7 @@ const STATE_PRESENTATIONS: [(AppState, TrayPresentation); 13] = [ TrayPresentation { icon_key: "syncing", status: "Active", - label: "Synchronization Scheduled", + label: "Waiting to synchronize", user_paused: false, }, ), @@ -216,7 +216,7 @@ mod tests { TrayPresentation { icon_key: "syncing", status: "Active", - label: "Synchronization Scheduled", + label: "Waiting to synchronize", user_paused: false, }, ), diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 2b9a02c..dbb7f7f 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -388,7 +388,6 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Synchronization Error", "Error de sincronización"), ("Synchronization Folders", "Carpetas de sincronización"), ("Synchronization Log", "Registro de sincronización"), - ("Synchronization Scheduled", "Sincronización programada"), ("Synchronization blocked", "Sincronización bloqueada"), ("Synchronization blocked: no saved credentials", "Sincronización bloqueada: no hay credenciales guardadas"), ("Synchronization blocked: password keyring is locked", "Sincronización bloqueada: el almacén de contraseñas está bloqueado"), @@ -399,7 +398,6 @@ 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 scheduled", "Sincronización programada"), ("Synchronize this account now", "Sincroniza esta cuenta ahora"), ("Synchronized", "Sincronizado"), ("Synchronized with conflicts — review the log", "Sincronizado con conflictos: revisa el registro"), @@ -475,7 +473,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Waiting for another account to finish…", "Esperando a que otra cuenta termine…"), ("Waiting for authorization in your browser…", "Esperando autorización en el navegador…"), ("Waiting for local changes to settle", "Esperando a que los cambios locales se asienten"), - ("Waiting to synchronize", "Esperando para sincronizar"), + ("Waiting to synchronize", "A la espera"), ("What's New", "Novedades"), ("When a remote folder is configured, the --path argument is passed to nextcloudcmd; leaving the field as / keeps the previous root-to-root behaviour.", "Cuando se configura una carpeta remota, se pasa el argumento --path a nextcloudcmd; dejar el campo como / mantiene el comportamiento anterior de raíz a raíz."), ("Wi-Fi", "Wi-Fi"),