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
20 changes: 14 additions & 6 deletions po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ msgstr "Restaurar desde Nextcloud"

#: src/nextsync/application.py:634
msgid "Approve These Deletions Once"
msgstr "Aprobar estas eliminaciones una vez"
msgstr "Aprobar estos borrados masivos una vez"

#: src/nextsync/application.py:656
msgid "Finishing synchronization"
Expand Down Expand Up @@ -950,11 +950,11 @@ msgstr "Llavero de contraseñas bloqueado"

#: src/nextsync/ui/folder_status.py:29 src/nextsync/ui/main_window.py:148
msgid "Review Deletions"
msgstr "Revisar eliminaciones"
msgstr "Revisar borrados masivos"

#: src/ui/folder_status.rs
msgid "Review deletions"
msgstr "Revisar eliminaciones"
msgstr "Revisar borrados masivos"

#: src/nextsync/ui/folder_status.py:97
msgid "Folder options"
Expand Down Expand Up @@ -2541,6 +2541,10 @@ msgstr "Proveedor de sincronización"
msgid "Synchronization blocked: password keyring is locked"
msgstr "Sincronización bloqueada: el almacén de contraseñas está bloqueado"

#: src/core/account_runtime.rs
msgid "Synchronization blocked: the server is unreachable"
msgstr "Sincronización bloqueada: el servidor no está disponible"

#: src/core/scheduler.rs
msgid "Waiting for local changes to settle"
msgstr "Esperando a que los cambios locales se asienten"
Expand Down Expand Up @@ -2583,8 +2587,12 @@ msgid "Waiting for a network connection"
msgstr "Esperando una conexión de red"

#: src/core/scheduler.rs
msgid "Waiting for another account to finish…"
msgstr "Esperando a que otra cuenta termine…"
msgid "Waiting for another folder to finish…"
msgstr "Esperando a que termine otra carpeta…"

#: src/core/scheduler.rs
msgid "Waiting for {folder} to finish…"
msgstr "Esperando a que termine {folder}…"

#: src/core/scheduler.rs
msgid "Will pause after the current synchronization"
Expand Down Expand Up @@ -2632,7 +2640,7 @@ msgstr "Estos borrados se propagarán al servidor al sincronizar."

#: src/ui/main_window.rs
msgid "No deletions are pending review."
msgstr "No hay eliminaciones pendientes de revisión."
msgstr "No hay borrados masivos pendientes de revisión."

#: src/ui/conflict_resolver.rs
msgid "Deletions"
Expand Down
74 changes: 61 additions & 13 deletions src/core/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,10 +611,15 @@ impl SchedulerInner {
};
if !acquired {
self.queue.extend(reasons.iter().copied());
self.state.set(
AppState::SyncQueued,
t("Waiting for another account to finish…"),
);
// Issue #165: name the folder the queue is waiting on (when it
// is a tracked holder), instead of the generic "another
// account" wording. Falls back to the generic message when no
// tracked folder is available.
let message = match permit.active_folder_name() {
Some(name) => t("Waiting for {folder} to finish…").replace("{folder}", &name),
None => t("Waiting for another folder to finish…").to_string(),
};
self.state.set(AppState::SyncQueued, message);
let source = self.source.clone();
let weak = self.self_ref.clone();
permit.wait_for_release(Box::new(move || {
Expand Down Expand Up @@ -648,13 +653,17 @@ impl SchedulerInner {
if self.stopped {
return;
}
let ran = match outcome {
// `conflicted` drives the post-run feedback handling: a run that ended
// with conflicted copies must not queue another reconciliation in a
// loop (issue #165); the row already carries the "review the log"
// message and the sync stays parked until the user addresses it.
let (ran, conflicted) = match outcome {
SyncOutcome::Success => {
self.keyring_locked = false;
self.auth_required = false;
self.ever_synced = true;
self.set_idle_state();
true
(true, false)
}
SyncOutcome::Conflict => {
self.keyring_locked = false;
Expand All @@ -664,7 +673,7 @@ impl SchedulerInner {
AppState::IdleOk,
t("Synchronized with conflicts — review the log"),
);
true
(true, true)
}
SyncOutcome::AuthFailed => {
self.keyring_locked = false;
Expand All @@ -675,7 +684,7 @@ impl SchedulerInner {
AppState::AuthRequired,
t("Credentials rejected. Sign in again in Account settings."),
);
false
(false, false)
}
SyncOutcome::NoCredentials => {
self.keyring_locked = false;
Expand All @@ -686,19 +695,19 @@ impl SchedulerInner {
AppState::AuthRequired,
t("No saved credentials. Use Sign in again in Account settings."),
);
false
(false, false)
}
SyncOutcome::KeyringLocked => {
self.keyring_locked = true;
self.state
.set(AppState::KeyringLocked, t("Password keyring is locked"));
false
(false, false)
}
SyncOutcome::Failed => {
self.keyring_locked = false;
self.state
.set(AppState::Error, t("Synchronization failed — view the log"));
true
(true, false)
}
SyncOutcome::NetworkError => {
// Issue #162: the server itself is unreachable even though the
Expand All @@ -711,7 +720,7 @@ impl SchedulerInner {
self.keyring_locked = false;
self.state
.set(AppState::Offline, t("Waiting for a network connection"));
false
(false, false)
}
};
// Fase 3: after a successful run the guard baseline is refreshed so
Expand All @@ -735,7 +744,15 @@ impl SchedulerInner {
flag.set(false);
}
if ran {
if self.inotify_during_sync {
if conflicted {
// Issue #165: a conflicted run must not trigger a follow-up
// reconciliation from local feedback (that is what keeps a
// folder with unresolved conflicts bouncing). Drop any local
// feedback queued during the run and let the interval/remote
// triggers retry on their own cadence.
self.queue.discard(Trigger::LocalInotify);
self.feedback_followup_pending = false;
} else if self.inotify_during_sync {
if feedback_followup {
// Suppress only the local feedback from the reconciliation
// itself; manual/remote triggers stay queued.
Expand Down Expand Up @@ -1205,6 +1222,37 @@ mod tests {
assert_eq!(scheduler.queue_len(), 1);
}

/// Issue #165: a run that ends with conflicted copies must not re-queue
/// the local feedback in a loop. The queue is left empty (only the
/// interval/remote triggers will retry later) and the folder stays in the
/// 'review the log' state until the user addresses the conflict.
#[test]
fn conflicted_run_does_not_requeue_local_feedback() {
let (scheduler, source, runner) = make_scheduler(None);
scheduler.request(Trigger::Manual);
run_idle(&source);
assert_eq!(runner.0.borrow().start_calls, 1);
// A local change arrives during the run, exactly like the success case.
scheduler.request(Trigger::LocalInotify);
finish(&runner, SyncOutcome::Conflict);
assert_eq!(
scheduler.state().snapshot().state,
AppState::IdleOk,
"the folder stays IdleOk with the conflict message"
);
assert_eq!(
scheduler.state().snapshot().message,
"Synchronized with conflicts — review the log"
);
// Unlike the success case the local feedback is NOT re-enqueued, so the
// folder does not bounce back into a sync loop.
assert_eq!(
scheduler.queue_len(),
0,
"no feedback re-queued on conflict"
);
}

#[test]
fn stop_removes_pending_sources_and_cancels_process() {
let (scheduler, source, runner) = make_scheduler(None);
Expand Down
14 changes: 14 additions & 0 deletions src/core/sync_permit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ impl SyncPermit {
.any(|active| paths_overlap(active, &canonical))
}

/// The display name of the oldest tracked holder currently running, if
/// any. Returns the folder name (the last path component) so a queued
/// scheduler can say which folder it is waiting on (issue #165). `None`
/// when no holder is tracked (a plain `try_acquire` holder, or idle).
pub fn active_folder_name(&self) -> Option<String> {
let inner = self.inner.borrow();
inner
.roots
.first()
.and_then(|root| root.file_name())
.and_then(|name| name.to_str())
.map(str::to_owned)
}

/// Release one slot and wake the oldest waiter, if any.
///
/// The woken callback runs synchronously (Python semantics); it must not
Expand Down
61 changes: 60 additions & 1 deletion src/ui/folder_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,16 @@ fn render(
) && !snapshot.message.is_empty()
{
parts.push(snapshot.message.clone());
} else if snapshot.state == AppState::SyncQueued && !snapshot.message.is_empty() {
// Issue #165: surface the specific reason the run is queued (e.g.
// waiting behind the shared permit) instead of the generic
// "Waiting to synchronize" label. When the message is exactly the
// generic label the row keeps the label and does not repeat itself.
if snapshot.message != status {
parts.push(snapshot.message.clone());
} else {
parts.push(status.to_string());
}
} else {
parts.push(status.to_string());
}
Expand Down Expand Up @@ -752,11 +762,21 @@ mod tests {
assert!(!row.local_size.is_visible());
// Outside the synchronized state the synced-in-local segment
// disappears: queued and syncing rows show only the status.
state.set(AppState::SyncQueued, "queued");
state.set(AppState::SyncQueued, "Waiting to synchronize");
assert_eq!(
row.row.subtitle().as_deref(),
Some("Waiting to synchronize")
);
// A specific blocking reason replaces the generic label (issue
// #165).
state.set(
AppState::SyncQueued,
"Waiting for another folder to finish…",
);
assert_eq!(
row.row.subtitle().as_deref(),
Some("Waiting for another folder to finish…")
);
state.set(AppState::IdleOk, "ok");
assert_eq!(row.row.subtitle().as_deref(), Some("Synced in local docs"));
// Live progress (issue #86): a progress event shows the line,
Expand All @@ -774,6 +794,45 @@ mod tests {
});
}

#[test]
fn queued_row_surfaces_the_blocking_reason() {
crate::ui::test_helpers::gtk_smoke(|| {
set_locale(Locale::English);
let folder = FolderConfig {
id: "f1".to_string(),
local_root: "/tmp/a".to_string(),
remote_path: "/docs".to_string(),
space_id: None,
size_confirmed: false,
};
let state = StateController::new(AppState::SyncQueued);
let row = FolderStatusRow::new(
folder,
Some(state.clone()),
FolderRowCallbacks::default(),
None,
None,
);
// The generic wait shows the generic label when there is no
// more specific reason (no message).
assert_eq!(
row.row.subtitle().as_deref(),
Some("Waiting to synchronize")
);
// Issue #165: with a specific blocking reason present, the row
// shows it instead of the generic label.
state.set(
AppState::SyncQueued,
"Waiting for another folder to finish…",
);
assert_eq!(
row.row.subtitle().as_deref(),
Some("Waiting for another folder to finish…")
);
reset_locale();
});
}

#[test]
fn local_tree_size_sums_files_and_skips_symlinks() {
let dir = tempfile::tempdir().unwrap();
Expand Down
66 changes: 50 additions & 16 deletions src/ui/main_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,11 @@ impl AccountView {
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let connected = !matches!(
runtime.state().snapshot().state,
crate::state::AppState::Offline | crate::state::AppState::Error
);
let light = gtk4::Image::builder().pixel_size(22).build();
light.set_icon_name(Some(if connected {
"nextsync-state-globe"
} else {
"nextsync-state-globe-off"
}));
// Issue #165: use the same severity mapping the live subscription
// applies (summary_light_for), so the initial render and the updates
// agree instead of flipping between globes and status icons.
light.set_icon_name(Some(summary_light_for(runtime.state().snapshot().state)));
line_one.append(&light);
// The status label doubles as the anti-race guard for the
// background quota fetch (detached rows keep their text).
Expand Down Expand Up @@ -1906,15 +1901,26 @@ pub fn summary_light_for(state: crate::state::AppState) -> &'static str {
}
}

/// The connection text for the account summary card. Only `Offline` reads
/// "Not connected"; every other state implies the server is reachable (the
/// light already carries the severity, issue #129).
/// The connection text for the account summary card, mirroring the severity
/// the light already conveys (issue #129): not just "Connected", but a text
/// that matches the state so the card is not contradictory (issue #165).
///
/// - Healthy/paused/queued/syncing states read "Connected" (or a specific
/// positive/neutral status) — the server is reachable.
/// - `Offline` reads "Not connected".
/// - Problem states (error/auth/keyring/delete review) do not read
/// "Connected": they surface a clear attention message instead of a lying
/// green-ish label next to a red light.
pub fn summary_connection_text(state: crate::state::AppState) -> &'static str {
use crate::state::AppState;
if state == AppState::Offline {
t("Not connected")
} else {
t("Connected")
match state {
AppState::Offline => t("Not connected"),
AppState::Error => t("Synchronization failed"),
AppState::AuthRequired => t("Credentials rejected"),
AppState::KeyringLocked => t("Password keyring is locked"),
AppState::DeleteReview => t("Review deletions"),
AppState::Unconfigured => t("Not connected"),
_ => t("Connected"),
}
}

Expand Down Expand Up @@ -2239,6 +2245,34 @@ mod tests {
);
}

#[test]
fn summary_connection_text_matches_the_light() {
// Issue #165: the text must not contradict the light. Problem states
// read a clear message (the red light), not a lying "Connected".
use crate::state::AppState;
set_locale(Locale::English);
assert_eq!(summary_connection_text(AppState::IdleOk), "Connected");
assert_eq!(summary_connection_text(AppState::Syncing), "Connected");
assert_eq!(summary_connection_text(AppState::Offline), "Not connected");
assert_eq!(
summary_connection_text(AppState::Error),
"Synchronization failed"
);
assert_eq!(
summary_connection_text(AppState::AuthRequired),
"Credentials rejected"
);
assert_eq!(
summary_connection_text(AppState::KeyringLocked),
"Password keyring is locked"
);
assert_eq!(
summary_connection_text(AppState::DeleteReview),
"Review deletions"
);
reset_locale();
}

#[test]
fn main_window_construction_smoke() {
// Must run through the shared GTK test worker: a second `gtk4::init()`
Expand Down
Loading
Loading