Skip to content

Commit c43d33b

Browse files
committed
Split the autosync-paused hint into guidance + reason lines
The paused reason now shows on its own line under a fixed guidance line ('resolve, then push manually to resume'); the backend stops appending that guidance to the reason message, so the data carries only the raw reason and each UI surface owns the guidance.
1 parent 78680eb commit c43d33b

4 files changed

Lines changed: 84 additions & 92 deletions

File tree

quilt-sync/src-tauri/src/autopull/reporter.rs

Lines changed: 14 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -57,32 +57,17 @@ pub struct PausedEvent {
5757
pub message: Option<String>,
5858
}
5959

60-
/// Trailing hint we append to every `Other(_)` paused message.
61-
///
62-
/// Two distinct gotchas this addresses:
63-
/// 1. **Pause persistence.** Once a namespace is in `inner.paused` the
64-
/// watcher's tick `continue`s past it, so it never re-evaluates with
65-
/// fresh settings — the user must click an explicit action
66-
/// (Commit & Push / Pull / Push / Set Remote) for `clear_paused` to
67-
/// fire. Without this hint, users see the banner sitting there even
68-
/// after they've fixed the underlying issue and assume the app is
69-
/// stuck.
70-
/// 2. **Stale message.** The text in this banner is the error from the
71-
/// publish attempt that paused the namespace. If the user has since
72-
/// changed publish settings (via the Settings UI form, which does
73-
/// update the in-memory cache), the *current* state may no longer
74-
/// match what the message describes.
75-
const OTHER_PAUSED_HINT: &str = "Autopush is paused on this package — the watcher will not retry until you click \
76-
Commit & Push manually. The message above may be stale if you have already updated \
77-
publish settings.";
78-
7960
impl PausedEvent {
61+
/// `message` carries only the raw refusal reason. The "resolve, then
62+
/// push manually to resume" guidance is presentation and lives in the
63+
/// UI (it once was appended here, which mixed data with guidance and
64+
/// made the banner unreadable).
8065
pub fn from_reason(namespace: &Namespace, reason: &PausedReason) -> Self {
8166
let (reason_str, message) = match reason {
8267
PausedReason::PendingChanges => ("pendingChanges", None),
8368
PausedReason::PendingCommit => ("pendingCommit", None),
8469
PausedReason::Diverged => ("diverged", None),
85-
PausedReason::Other(msg) => ("other", Some(format!("{msg}. {OTHER_PAUSED_HINT}"))),
70+
PausedReason::Other(msg) => ("other", Some(msg.clone())),
8671
};
8772
Self {
8873
namespace: namespace.to_string(),
@@ -280,42 +265,27 @@ mod tests {
280265
}
281266

282267
#[test]
283-
fn paused_event_from_reason_other_carries_message_with_hint() {
268+
fn paused_event_from_reason_other_carries_raw_reason_only() {
284269
let ns = quilt_uri::Namespace::from(("acme", "demo"));
285270
let ev = PausedEvent::from_reason(
286271
&ns,
287272
&PausedReason::Other("workflow rejected metadata".to_string()),
288273
);
289274
assert_eq!(ev.reason, "other");
290275

291-
// The raw error string is preserved at the front of the
292-
// message so the user sees what went wrong, and the
293-
// restart-cached-settings hint is appended so they aren't
294-
// stuck wondering why hand-editing publish_settings.json had
295-
// no effect.
296-
let msg = ev.message.as_deref().expect("Other should carry a message");
297-
assert!(
298-
msg.starts_with("workflow rejected metadata"),
299-
"raw error should lead the message, got: {msg}"
300-
);
301-
assert!(
302-
msg.contains("Autopush is paused"),
303-
"hint should make the paused-direction explicit, got: {msg}"
304-
);
305-
assert!(
306-
msg.contains("Commit & Push"),
307-
"hint should name the retry action, got: {msg}"
308-
);
309-
assert!(
310-
msg.contains("may be stale"),
311-
"hint should warn the error string may be stale, got: {msg}"
276+
// `message` is exactly the raw refusal reason — no appended
277+
// guidance. The "resolve, then push manually" line is presentation
278+
// and is added by each UI surface, not baked into the data.
279+
assert_eq!(
280+
ev.message.as_deref(),
281+
Some("workflow rejected metadata"),
282+
"Other should carry the raw reason with no appended hint"
312283
);
313284

314-
// Serializes as camelCase, with the full message (raw error + hint).
285+
// Serializes as camelCase with the raw reason as the message.
315286
let json = serde_json::to_string(&ev).unwrap();
316287
assert!(json.contains(r#""reason":"other""#), "got: {json}");
317288
assert!(json.contains("workflow rejected metadata"), "got: {json}");
318-
assert!(json.contains("Autopush is paused"), "got: {json}");
319289
assert!(json.contains(r#""namespace":"acme/demo""#), "got: {json}");
320290
}
321291
}

quilt-sync/ui/assets/css/views/status.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
margin: 0;
2525
}
2626

27+
.detail {
28+
color: var(--q-ui-palette-text-secondary);
29+
font-size: 0.875rem;
30+
margin: var(--q-ui-size-1) 0 0;
31+
}
32+
2733
.action {
2834
display: flex;
2935
gap: var(--q-ui-size-2);

quilt-sync/ui/src/pages/installed_package/status_banner.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,17 +146,19 @@ pub(super) fn StatusBanner(
146146
// `InstalledPackage` filters everything else out so we
147147
// don't double-banner Diverged / Behind / Ahead, which
148148
// are already covered by the status-driven `content`
149-
// below. `Other(_)` always populates `message` with the
150-
// raw error plus the `OTHER_PAUSED_HINT` suffix, so we
151-
// render it verbatim. The `unwrap_or_else` is a
152-
// belt-and-braces fallback for malformed events.
153-
let description = ev
154-
.message
155-
.unwrap_or_else(|| "Autosync paused".to_string());
149+
// below. `message` carries just the raw refusal reason;
150+
// the guidance line ("resolve, then push manually to
151+
// resume") is presentation, added here.
152+
let reason = ev.message;
156153
view! {
157154
<div class="qui-status">
158155
<div class="root">
159-
<h2 class="description">{description}</h2>
156+
<div class="text">
157+
<h2 class="description">
158+
"Autosync paused. Resolve the issue, then push manually to resume."
159+
</h2>
160+
{reason.map(|r| view! { <p class="detail">{r}</p> })}
161+
</div>
160162
</div>
161163
</div>
162164
}

quilt-sync/ui/src/pages/installed_packages_list.rs

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,24 +41,32 @@ type PausedMapSignal = RwSignal<HashMap<String, String>>;
4141
/// whether it has a catalog host configured, and any autosync pause
4242
/// message. Returns `None` for a healthy row (no third line, not red).
4343
///
44-
/// Pure so it can be unit-tested. Mirrors the detail-page status banner:
45-
/// a `paused` row shows the refusal reason; an `error` row shows a
46-
/// sign-in or no-remote hint depending on whether a remote host exists.
47-
fn error_hint(status: &str, has_host: bool, paused_message: Option<&str>) -> Option<String> {
44+
/// The guidance line shown above a paused row's reason: paused rows stay
45+
/// paused until the user acts, so it names the resume action rather than
46+
/// leaving them to wonder why autosync stopped.
47+
const PAUSED_GUIDANCE: &str = "Autosync paused. Resolve the issue, then push manually to resume.";
48+
49+
/// The one or two hint lines shown under a row's URI, error-coloured; empty
50+
/// when the row is healthy (which is also the not-red condition).
51+
///
52+
/// Pure so it can be unit-tested. A `paused` row shows the guidance line
53+
/// followed by the raw refusal reason (when one is known); an `error` row
54+
/// shows a sign-in or no-remote hint depending on whether a remote host
55+
/// exists.
56+
fn hint_lines(status: &str, has_host: bool, paused_message: Option<&str>) -> Vec<String> {
4857
if status == "paused" || paused_message.is_some() {
49-
return Some(match paused_message {
50-
Some(msg) => format!("Autosync paused: {msg}"),
51-
None => "Autosync paused".to_string(),
52-
});
58+
let mut lines = vec![PAUSED_GUIDANCE.to_string()];
59+
lines.extend(paused_message.map(str::to_string));
60+
return lines;
5361
}
5462
if status == "error" {
55-
return Some(if has_host {
63+
return vec![if has_host {
5664
"Unable to check remote status — sign in again".to_string()
5765
} else {
5866
"No remote configured".to_string()
59-
});
67+
}];
6068
}
61-
None
69+
Vec::new()
6270
}
6371

6472
// ── Installed Packages List page ──
@@ -330,14 +338,14 @@ fn PackageItem(
330338
let namespace_display = data.namespace.clone();
331339
let remote_display = data.remote_display.clone();
332340

333-
// Third-line attention hint. Red state and the reason line are both
334-
// driven by `hint`: it is `Some` exactly when the row needs attention
335-
// (autosync-paused, or a remote error), `None` for a healthy row.
341+
// Attention hint lines under the URI. Red state and the lines are both
342+
// driven by `hint`: it is non-empty exactly when the row needs attention
343+
// (autosync-paused, or a remote error), empty for a healthy row.
336344
let has_host = data.uri.as_ref().and_then(util::host_str).is_some();
337345
let ns_for_hint = data.namespace.clone();
338346
let hint = Signal::derive(move || {
339347
let paused_message = paused_map.with(|map| map.get(&ns_for_hint).cloned());
340-
status.with(|s| error_hint(s, has_host, paused_message.as_deref()))
348+
status.with(|s| hint_lines(s, has_host, paused_message.as_deref()))
341349
});
342350

343351
// Build menu buttons
@@ -353,10 +361,10 @@ fn PackageItem(
353361
);
354362

355363
view! {
356-
<li class=move || if hint.get().is_some() {
357-
"qui-installed-package-item error"
358-
} else {
364+
<li class=move || if hint.with(Vec::is_empty) {
359365
"qui-installed-package-item"
366+
} else {
367+
"qui-installed-package-item error"
360368
}>
361369
<a class="link" href=pkg_href>
362370
<span class="item-primary">{namespace_display}</span>
@@ -366,9 +374,9 @@ fn PackageItem(
366374
{uri}
367375
</span>
368376
})}
369-
{move || hint.get().map(|h| view! {
370-
<span class="item-error-hint">{h}</span>
371-
})}
377+
{move || hint.get().into_iter().map(|line| view! {
378+
<span class="item-error-hint">{line}</span>
379+
}).collect::<Vec<_>>()}
372380
</a>
373381
<Show when=move || refreshing.get()>
374382
<div class="q-spinner-inline" />
@@ -695,59 +703,65 @@ fn CreatePackagePopup(
695703

696704
#[cfg(test)]
697705
mod tests {
698-
use super::error_hint;
706+
use super::{PAUSED_GUIDANCE, hint_lines};
699707

700708
#[test]
701-
fn paused_with_reason_shows_autosync_paused_hint() {
709+
fn paused_with_reason_shows_guidance_then_reason() {
702710
assert_eq!(
703-
error_hint("paused", true, Some("workflow rejected metadata")),
704-
Some("Autosync paused: workflow rejected metadata".to_string())
711+
hint_lines("paused", true, Some("workflow rejected metadata")),
712+
vec![
713+
PAUSED_GUIDANCE.to_string(),
714+
"workflow rejected metadata".to_string(),
715+
]
705716
);
706717
// A snapshot-seeded pause carries its reason even when the row's
707718
// own status string was refreshed to something else on mount.
708719
assert_eq!(
709-
error_hint("up_to_date", false, Some("hash mismatch")),
710-
Some("Autosync paused: hash mismatch".to_string())
720+
hint_lines("up_to_date", false, Some("hash mismatch")),
721+
vec![PAUSED_GUIDANCE.to_string(), "hash mismatch".to_string()]
711722
);
712723
}
713724

714725
#[test]
715-
fn paused_without_reason_falls_back_to_generic() {
726+
fn paused_without_reason_shows_guidance_only() {
716727
assert_eq!(
717-
error_hint("paused", true, None),
718-
Some("Autosync paused".to_string())
728+
hint_lines("paused", true, None),
729+
vec![PAUSED_GUIDANCE.to_string()]
719730
);
720731
}
721732

722733
#[test]
723-
fn paused_reason_takes_precedence_over_error_status() {
734+
fn paused_takes_precedence_over_error_status() {
724735
// A row that is both `error` and has a pause reason shows the pause
725-
// reason — the more specific, actionable message wins.
736+
// guidance + reason — the more specific, actionable message wins.
726737
assert_eq!(
727-
error_hint("error", true, Some("workflow rejected metadata")),
728-
Some("Autosync paused: workflow rejected metadata".to_string())
738+
hint_lines("error", true, Some("workflow rejected metadata")),
739+
vec![
740+
PAUSED_GUIDANCE.to_string(),
741+
"workflow rejected metadata".to_string(),
742+
]
729743
);
730744
}
731745

732746
#[test]
733747
fn error_with_host_prompts_sign_in() {
734748
assert_eq!(
735-
error_hint("error", true, None),
736-
Some("Unable to check remote status — sign in again".to_string())
749+
hint_lines("error", true, None),
750+
vec!["Unable to check remote status — sign in again".to_string()]
737751
);
738752
}
739753

740754
#[test]
741755
fn error_without_host_reports_no_remote() {
742756
assert_eq!(
743-
error_hint("error", false, None),
744-
Some("No remote configured".to_string())
757+
hint_lines("error", false, None),
758+
vec!["No remote configured".to_string()]
745759
);
746760
}
747761

748762
#[test]
749763
fn healthy_row_has_no_hint() {
750-
assert_eq!(error_hint("up_to_date", true, None), None);
751-
assert_eq!(error_hint("ahead", false, None), None);
764+
assert!(hint_lines("up_to_date", true, None).is_empty());
765+
assert!(hint_lines("ahead", false, None).is_empty());
752766
}
753767
}

0 commit comments

Comments
 (0)