Skip to main content

max / audiofiles

Flip cloud sync to the described screen Sync serves from `quasi::sync` and `ui/sync_panel.rs` is gone, both of its windows with it: one `draw_sync` call answers a manager and no manager alike. The flip found a real defect in the described screen and fixing it is most of this commit. `Unconfigured` reported `State::Disconnected` with an error, so the screen offered a Connect that refuses and a Dismiss for an error `clear_error` cannot clear -- two controls that do nothing, which is the shape `discovery` in the detail panel argues against. Unavailable is not disconnected: disconnected means there is a service and you are not on it, and unavailable means there is no service to be on. `Sync::available` says which, defaulted true because only `Unconfigured` cannot sync, and the screen offers nothing in that state. That is what the shipped second window did, in two sentences and no controls. Three things came off the deleted panel. Two are caches the window owns and neither is describable: the auth URL, dropped once authentication is over so a stale PKCE state cannot reappear, and the per-vault storage numbers, dropped when the panel closes so reopening fetches fresh ones. Both moved into `draw_sync`. The third is deleted rather than moved. `ConfirmAction::DisconnectSync` and `pending_disconnect` existed because the shipped Disconnect raised a confirm from a dispatcher with no `SyncManager` handle, so the actual call had to wait a frame and land in the panel. The described Disconnect carries `Act::confirm` and its route holds the manager, so the whole detour goes: one variant, one field, and two match arms.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 21:31 UTC
Signed with PGP, not checked
Commit: 89e9d03b955a117350ee7918299a55c4da237c7f
Parent: fe7d76f
10 files changed, +84 insertions, -560 deletions
@@ -258,17 +258,11 @@
258 258 crate::quasi::panel::draw_sweep(ctx, state);
259 259 }
260 260
261 - // Sync panel overlay
261 + // Sync panel overlay. One call for both cases now: `None` is `Unconfigured`,
262 + // which says syncing is unavailable and offers nothing, where the shipped
263 + // side had a second window for it.
262 264 if state.sync.show_panel {
263 - // The described one beside it, on the same toggle. `None` is the case
264 - // the shipped side answers with a whole second window.
265 - #[cfg(feature = "quasi")]
266 265 crate::quasi::panel::draw_sync(ctx, state, sync_manager);
267 - if let Some(sync) = sync_manager {
268 - crate::ui::sync_panel::draw_sync_panel(ctx, state, sync);
269 - } else {
270 - crate::ui::sync_panel::draw_sync_not_configured(ctx, state);
271 - }
272 266 }
273 267
274 268 // Floating sample editor window
@@ -271,6 +271,22 @@
271 271 /// is describable at all — see [`sync`]'s header, where that is compared against
272 272 /// goingson ruling its own sync section out.
273 273 pub trait Sync {
274 + /// Whether syncing is possible here at all.
275 + ///
276 + /// Not the same as disconnected, and the flip found the difference
277 + /// (2026-08-22). Disconnected means "there is a service and you are not on
278 + /// it", which is what `Connect` answers. `false` here means there is no
279 + /// service to be on: no vault is open, or this build has no manager. The
280 + /// screen offers nothing in that state, which is what the shipped panel did
281 + /// with a whole second window, rather than offering a `Connect` that
282 + /// refuses and a `Dismiss` for an error that cannot be cleared.
283 + ///
284 + /// Defaulted, because every real manager can sync and only
285 + /// [`Unconfigured`] cannot.
286 + fn available(&self) -> bool {
287 + true
288 + }
289 +
274 290 /// Where the flow has got to.
275 291 fn status(&self) -> Status;
276 292
@@ -468,6 +484,10 @@
468 484 pub struct Unconfigured;
469 485
470 486 impl Sync for Unconfigured {
487 + fn available(&self) -> bool {
488 + false
489 + }
490 +
471 491 fn status(&self) -> Status {
472 492 Status {
473 493 state: State::Disconnected,
@@ -113,12 +113,36 @@
113 113 }
114 114 }
115 115
116 - /// Draw the described sync window, and act on whatever was pressed.
116 + /// Draw the sync window, and act on whatever was pressed.
117 117 ///
118 - /// `sync` is `None` when the app has no manager, which is the case the shipped
119 - /// panel answers with a whole second window. Here it is [`Unconfigured`], which
120 - /// reports disconnected and refuses to connect.
118 + /// `sync` is `None` when the app has no manager, and it becomes
119 + /// [`Unconfigured`], which says syncing is unavailable and offers nothing. The
120 + /// shipped panel had a second window for that case.
121 121 pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
122 + // Two pieces of housekeeping that came off `ui::sync_panel::draw_sync_panel`
123 + // when it was deleted. Neither is describable and neither is a control: they
124 + // are caches the window owns, dropped when what they were about is over.
125 + //
126 + // The auth URL is only meaningful while the Copy URL fallback is on screen,
127 + // and keeping it would show a stale PKCE state if the panel were reopened.
128 + if let Some(manager) = sync
129 + && !matches!(
130 + manager.status().state,
131 + audiofiles_sync::SyncState::Authenticating
132 + )
133 + && state.sync.auth_url.is_some()
134 + {
135 + state.sync.auth_url = None;
136 + }
137 + // The per-vault storage numbers go when the panel closes, so reopening
138 + // fetches fresh ones: the user may have imported or deleted since.
139 + if !state.sync.show_panel {
140 + state.sync.vfs_storage_fetched = false;
141 + state.sync.vfs_storage_cache.clear();
142 + state.sync.synced_bytes = None;
143 + state.sync.cap_picker_gib = None;
144 + }
145 +
122 146 let intents = RefCell::new(Vec::new());
123 147 let mut runtime = state.described.sync.take();
124 148 let stale = state.described.stale;
@@ -128,14 +152,7 @@
128 152 themes: themes(),
129 153 intents: &intents,
130 154 };
131 - let closed = window(
132 - ctx,
133 - "Cloud Sync (described)",
134 - &mut runtime,
135 - &host,
136 - "/sync",
137 - stale,
138 - );
155 + let closed = window(ctx, "Cloud Sync", &mut runtime, &host, "/sync", stale);
139 156 state.described.sync = runtime;
140 157 apply(ctx, state, sync, intents.into_inner());
141 158 if closed {
@@ -1081,3 +1081,22 @@
1081 1081 .assert(&described, &drawn);
1082 1082 }
1083 1083 }
1084 +
1085 + #[test]
1086 + fn the_unconfigured_sync_screen_serves_what_it_describes() {
1087 + // With no manager, which is the one sync state a test can stand up without
1088 + // a server. `Unconfigured` says syncing is unavailable and offers nothing,
1089 + // where the shipped side had a whole second window for it.
1090 + let (mut state, _dir) = fixture();
1091 + state.sync.show_panel = true;
1092 +
1093 + let described = described(&super::panel::described_screen(&state, "/sync"));
1094 + let drawn = shipped(|ui| {
1095 + super::panel::draw_sync(ui.ctx(), &mut state, None);
1096 + });
1097 +
1098 + described.addresses_resolve();
1099 + Parity::strict()
1100 + .in_a_window("Cloud Sync")
1101 + .assert(&described, &drawn);
1102 + }
@@ -301,6 +301,19 @@
301 301 .live()
302 302 .with(Node::page("Cloud Sync"));
303 303
304 + // Nothing to offer when there is nothing to offer it against. See
305 + // [`Sync::available`]: an unavailable manager is not a disconnected one, and
306 + // the flip found the described screen offering a `Connect` that refuses and
307 + // a `Dismiss` for an error `clear_error` cannot clear. The shipped panel
308 + // answered this state with a whole second window saying the same two
309 + // sentences and drawing no controls.
310 + if !sync.available() {
311 + return Screen::sidebar_content("Cloud Sync").with(
312 + body.with(Node::text("Cloud sync is unavailable."))
313 + .with(Node::text("Open a vault to enable sync.")),
314 + );
315 + }
316 +
304 317 body = match status.state {
305 318 State::Disconnected => disconnected(body),
306 319 State::Authenticating => authenticating(body),
@@ -114,11 +114,6 @@
114 114 Some(ConfirmAction::ReanalyzeOverwrite { sample_hashes, .. }) => {
115 115 self.start_analysis_flow(sample_hashes);
116 116 }
117 - Some(ConfirmAction::DisconnectSync { .. }) => {
118 - // The actual sync.disconnect() happens in sync_panel.rs next
119 - // frame, bulk_ops.rs runs without a SyncManager handle.
120 - self.sync.pending_disconnect = true;
121 - }
122 117 Some(ConfirmAction::RemoveFailedSamples { single_index, .. }) => match single_index {
123 118 Some(idx) => self.remove_failed_sample(idx),
124 119 None => self.remove_all_failed_samples(),
@@ -115,13 +115,6 @@
115 115 sample_hashes: Vec<(String, String)>,
116 116 overwrite_count: usize,
117 117 },
118 - /// Disconnect from cloud sync. Destructive because reconnecting requires
119 - /// re-entering the encryption password, a typo there would leave the cloud
120 - /// blob unreadable. `pending_changes` is surfaced in the detail line so the
121 - /// user knows whether unsynced work is at stake.
122 - DisconnectSync {
123 - pending_changes: i64,
124 - },
125 118 /// Permanently remove analysis-failed samples from the content store. The
126 119 /// post-import error review surfaces these with per-row and bulk delete
127 120 /// buttons; both gate through this variant so a stray click can't purge
@@ -736,11 +729,6 @@
736 729 /// `subscription_loading_at`, a closed browser tab or declined card would
737 730 /// otherwise leave every Subscribe / Change-tier button disabled forever.
738 731 pub checkout_loading_at: Option<std::time::Instant>,
739 - /// Set true by `execute_confirmed_action` when the user confirms a
740 - /// DisconnectSync. The sync panel consumes the flag next frame and calls
741 - /// `sync.disconnect()`. Decouples the confirm dispatch (which lives in
742 - /// `bulk_ops.rs` and has no SyncManager handle) from the sync action.
743 - pub pending_disconnect: bool,
744 732 /// Last URL returned by `sync.start_auth()`, cached so the Authenticating
745 733 /// screen can offer a Copy URL fallback when the user's browser didn't open.
746 734 pub auth_url: Option<String>,
@@ -786,7 +774,6 @@
786 774 subscription_loading_at: None,
787 775 checkout_loading: false,
788 776 checkout_loading_at: None,
789 - pending_disconnect: false,
790 777 auth_url: None,
791 778 vfs_storage_cache: std::collections::HashMap::new(),
792 779 vfs_storage_fetched: false,
@@ -13,7 +13,6 @@
13 13 pub mod overlays;
14 14 pub mod settings_panel;
15 15 pub mod sidebar;
16 - pub mod sync_panel;
17 16 pub mod theme;
18 17 pub mod toolbar;
19 18 pub mod widgets;
@@ -304,26 +304,6 @@
304 304 // so the affordance should match, render the confirm as danger.
305 305 true,
306 306 ),
307 - Some(ConfirmAction::DisconnectSync { pending_changes }) => {
308 - // Detail surfaces pending unsynced metadata (if any) plus the
309 - // always-true reminder that reconnecting requires the encryption
310 - // password, a typo there would brick the cloud blob (see C-1).
311 - let detail = if *pending_changes > 0 {
312 - format!(
313 - "{pending_changes} unsynced change{} will be discarded. You'll need your encryption password to reconnect.",
314 - if *pending_changes == 1 { "" } else { "s" },
315 - )
316 - } else {
317 - "You'll need your encryption password to reconnect.".to_string()
318 - };
319 - (
320 - "Disconnect sync",
321 - "Disconnect from cloud sync?".to_string(),
322 - Some(detail),
323 - "Disconnect",
324 - true,
325 - )
326 - }
327 307 Some(ConfirmAction::RemoveFailedSamples { single_index, count, name }) => {
328 308 let (prompt_str, detail_str) = match single_index {
329 309 Some(_) => {
@@ -1,987 +1,0 @@
1 - //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow.
2 -
3 - use egui;
4 - use makeover_immediate;
5 - use makeover_layout;
6 - use tracing::{error, warn};
7 -
8 - use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus};
9 -
10 - use crate::state::{BrowserState, ConfirmAction};
11 - use crate::storage_cap;
12 - use crate::ui::theme;
13 - use crate::ui::widgets;
14 -
15 - const GIB: i64 = 1024 * 1024 * 1024;
16 -
17 - fn format_cents(cents: i64) -> String {
18 - let dollars = cents / 100;
19 - let pennies = cents % 100;
20 - if pennies == 0 {
21 - format!("${dollars}")
22 - } else {
23 - format!("${dollars}.{pennies:02}")
24 - }
25 - }
26 -
27 - fn format_cap(cap_bytes: i64) -> String {
28 - let gib = cap_bytes / GIB;
29 - if gib >= 1024 {
30 - format!("{:.1} TiB", gib as f64 / 1024.0)
31 - } else {
32 - format!("{gib} GiB")
33 - }
34 - }
35 -
36 - /// Whether the encryption form can submit, and what is wrong with which field.
37 - ///
38 - /// The two messages are held apart rather than returned as one hint because
39 - /// they belong to different questions: a password too short is wrong with the
40 - /// password, and a mismatch is only ever wrong with the confirmation. A
41 - /// described field carries its own error, so a single message would have to be
42 - /// drawn detached from both fields to stay truthful — which is what this form
43 - /// did before it was described.
44 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 - struct EncryptionForm {
46 - can_submit: bool,
47 - password_error: Option<&'static str>,
48 - confirm_error: Option<&'static str>,
49 - }
50 -
51 - impl EncryptionForm {
52 - const SILENT: Self = Self {
53 - can_submit: false,
54 - password_error: None,
55 - confirm_error: None,
56 - };
57 - }
58 -
59 - /// Decide whether the encryption form can submit, and which field is wrong.
60 - /// Unlock (`has_server_key`) only needs a non-empty password. First-time setup
61 - /// requires a >=8-char password confirmed by a matching second entry; both
62 - /// errors stay `None` while the user is still mid-entry (so no error flashes
63 - /// prematurely).
64 - fn encryption_submit_state(has_server_key: bool, pw: &str, confirm: &str) -> EncryptionForm {
65 - if has_server_key {
66 - return EncryptionForm {
67 - can_submit: !pw.is_empty(),
68 - ..EncryptionForm::SILENT
69 - };
70 - }
71 - if pw.is_empty() {
72 - EncryptionForm::SILENT
73 - } else if pw.len() < 8 {
74 - EncryptionForm {
75 - password_error: Some("Password must be at least 8 characters."),
76 - ..EncryptionForm::SILENT
77 - }
78 - } else if confirm.is_empty() {
79 - EncryptionForm::SILENT
80 - } else if pw != confirm {
81 - EncryptionForm {
82 - confirm_error: Some("Passwords don't match."),
83 - ..EncryptionForm::SILENT
84 - }
85 - } else {
86 - EncryptionForm {
87 - can_submit: true,
88 - ..EncryptionForm::SILENT
89 - }
90 - }
91 - }
92 -
93 - /// Draw the sync settings panel as a floating window.
94 - pub fn draw_sync_panel(ctx: &egui::Context, state: &mut BrowserState, sync: &SyncManager) {
95 - // Consume any pending disconnect set by execute_confirmed_action last frame.
96 - // The confirm dispatcher in bulk_ops.rs runs without a SyncManager handle,
97 - // so the actual sync.disconnect() lands here.
98 - if state.sync.pending_disconnect {
99 - state.sync.pending_disconnect = false;
100 - sync.disconnect();
101 - state.status = "Disconnected from cloud sync.".to_string();
102 - }
103 -
104 - // Drop the cached auth URL once we've left the Authenticating state. It's
105 - // only meaningful while the Copy URL fallback is on screen, and lingering
106 - // would leak the PKCE state into a stale display if the user reopens the
107 - // panel later.
108 - if !matches!(sync.status().state, SyncState::Authenticating) && state.sync.auth_url.is_some() {
109 - state.sync.auth_url = None;
110 - }
111 -
112 - // Drop the per-VFS storage cache when the panel closes so reopening fetches
113 - // fresh numbers (the user may have imported/deleted between sessions).
114 - if !state.sync.show_panel {
115 - state.sync.vfs_storage_fetched = false;
116 - state.sync.vfs_storage_cache.clear();
117 - state.sync.synced_bytes = None;
118 - state.sync.cap_picker_gib = None;
119 - }
120 -
121 - let mut open = state.sync.show_panel;
122 - widgets::modal_window_with_open(
123 - ctx,
124 - "Cloud Sync",
125 - Some(&mut open),
126 - false,
127 - Some(360.0),
128 - |ui| {
129 - let status = sync.status();
130 -
131 - match &status.state {
132 - SyncState::Disconnected => {
133 - draw_disconnected(ui, state, sync);
134 - }
135 - SyncState::Authenticating => {
136 - draw_authenticating(ui, state, sync);
137 - }
138 - SyncState::NeedsEncryption { has_server_key } => {
139 - draw_needs_encryption(ui, state, sync, *has_server_key);
140 - }
141 - SyncState::Ready | SyncState::Syncing => {
142 - draw_ready(ui, state, sync, &status);
143 - }
144 - }
145 -
146 - // Error banner with Retry + Dismiss. Retry is only meaningful in
147 - // Ready/Syncing state (calls sync_now); in other states the user's
148 - // primary action is already on screen (Connect, Set Password), so
149 - // Retry hides itself and Dismiss is the only escape.
150 - if let Some(err) = status.last_error.clone() {
151 - ui.add_space(theme::space::bound());
152 - ui.separator();
153 - ui.add_space(theme::space::bound());
154 - widgets::raised_frame(ui, |ui| {
155 - ui.label(egui::RichText::new(err).color(theme::danger()));
156 - ui.add_space(theme::space::bound());
157 - ui.horizontal(|ui| {
158 - let retryable =
159 - matches!(status.state, SyncState::Ready | SyncState::Syncing,);
160 - if retryable && widgets::secondary_button(ui, "Retry").clicked() {
161 - sync.clear_last_error();
162 - sync.sync_now();
163 - }
164 - if widgets::secondary_button(ui, "Dismiss").clicked() {
165 - sync.clear_last_error();
166 - }
167 - });
168 - });
169 - }
170 - },
171 - );
172 - state.sync.show_panel = open;
173 - }
174 -
175 - /// Draw the subscription status/purchase section for blob sync.
176 - fn draw_subscription_section(ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager) {
177 - let sync_status = sync.status();
178 -
179 - // Loading-flag timeout: if a fetch or checkout never resolves, the panel
180 - // would otherwise spin "Checking subscription..." (or grey out every
181 - // Subscribe button) forever. After 30s without a response, clear the flag
182 - // so the user can retry. The status message is the only feedback channel
183 - // for this surface, see C-3's wiring of the same pattern.
184 - const LOADING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
185 - if let Some(at) = state.sync.subscription_loading_at
186 - && at.elapsed() >= LOADING_TIMEOUT
187 - && sync_status.subscription.is_none()
188 - {
189 - state.sync.subscription_loading = false;
190 - state.sync.subscription_loading_at = None;
191 - state.status = "Subscription check timed out. Click Retry to try again.".to_string();
192 - }
193 - if let Some(at) = state.sync.checkout_loading_at
194 - && at.elapsed() >= LOADING_TIMEOUT
195 - {
196 - state.sync.checkout_loading = false;
197 - state.sync.checkout_loading_at = None;
198 - state.status =
199 - "Checkout timed out. The browser tab may have closed; try again.".to_string();
200 - }
201 - // If the checkout / cap-change call reported an error, the sync manager surfaces
202 - // it in `last_error` (shown by the error banner above). Clear the checkout
203 - // loading flag immediately so the Subscribe / cap-change button re-enables for a
204 - // retry, rather than staying greyed out until the 30s timeout above. (Only the
205 - // checkout flag, subscription fetch failures don't set `last_error`, so a
206 - // general sync error must not interrupt the "Checking subscription..." spinner.)
207 - if sync_status.last_error.is_some() && state.sync.checkout_loading {
208 - state.sync.checkout_loading = false;
209 - state.sync.checkout_loading_at = None;
210 - }
211 -
212 - // Trigger initial fetch if not yet loaded
213 - if sync_status.subscription.is_none() && !state.sync.subscription_loading {
214 - state.sync.subscription_loading = true;
215 - state.sync.subscription_loading_at = Some(std::time::Instant::now());
216 - sync.fetch_subscription_status();
217 - }
218 -
219 - if state.sync.subscription_loading && sync_status.subscription.is_none() {
220 - ui.horizontal(|ui| {
221 - ui.label(egui::RichText::new("Checking subscription...").weak());
222 - if ui.small_button("Retry").clicked() {
223 - state.sync.subscription_loading_at = Some(std::time::Instant::now());
224 - sync.fetch_subscription_status();
225 - }
226 - });
227 - return;
228 - }
229 -
230 - // Once loaded, clear loading flags
231 - if sync_status.subscription.is_some() {
232 - state.sync.subscription_loading = false;
233 - state.sync.subscription_loading_at = None;
234 - state.sync.checkout_loading = false;
235 - state.sync.checkout_loading_at = None;
236 - }
237 -
238 - match &sync_status.subscription {
239 - Some(sub) if sub.active => {
240 - let limit = sub.storage_limit_bytes.unwrap_or(0);
241 - let used = sub.storage_used_bytes.unwrap_or(0);
242 - let interval = sub
243 - .interval
244 - .map_or("monthly", audiofiles_sync::BillingInterval::as_str);
245 -
246 - ui.label(format!("Subscribed: {} ({})", format_cap(limit), interval));
247 -
248 - if limit > 0 {
249 - let used_gb = used as f64 / GIB as f64;
250 - let limit_gb = limit as f64 / GIB as f64;
251 - let fraction = (used as f32) / (limit as f32);
252 - ui.add(
253 - egui::ProgressBar::new(fraction)
254 - .text(format!("{used_gb:.1} / {limit_gb:.0} GiB")),
255 - );
256 - }
257 -
258 - if let Some(pending) = sub.pending_storage_limit_bytes {
259 - ui.add_space(theme::space::hair());
260 - ui.label(
261 - egui::RichText::new(format!(
262 - "Pending: cap changes to {} at next renewal.",
263 - format_cap(pending)
264 - ))
265 - .weak(),
266 - );
267 - }
268 -
269 - // Say what a filling cap means before the upload that fails says it.
270 - // Without this the first news is a 402 from the blob route, which the
271 - // user meets as a sync that broke rather than a cap that filled.
272 - if storage_cap::nearly_full(used, limit) {
273 - ui.add_space(theme::space::hair());
274 - let full = used >= limit;
275 - let text = if full {
276 - "Your storage cap is full. New sample files are not uploading; \
277 - everything else still syncs."
278 - } else {
279 - "You are close to your storage cap. When it fills, new sample \
280 - files stop uploading and everything else keeps syncing."
281 - };
282 - ui.label(egui::RichText::new(text).color(if full {
283 - theme::danger()
284 - } else {
285 - theme::warning()
286 - }));
287 - }
288 -
289 - // Cap change for subscribed users.
290 - if let Some(pricing) = &sync_status.pricing {
291 - let pricing = pricing.clone();
292 - let interval_enum = BillingInterval::from_wire(interval);
293 - ui.add_space(theme::space::peer());
294 - ui.label(egui::RichText::new("Adjust cap:").weak());
295 - ui.label(
296 - egui::RichText::new(
297 - "An increase applies now. A decrease takes effect next cycle.",
298 - )
299 - .small()
300 - .color(theme::content_muted()),
301 - );
302 -
303 - // A cap that no longer covers the library is the one case where
304 - // re-proposing over the user's own answer is the point.
305 - if storage_cap::nearly_full(used, limit) {
306 - let need = synced_need(state);
307 - let proposed =
308 - storage_cap::proposed(need, pricing.min_cap_bytes, pricing.max_cap_bytes);
309 - if proposed > limit {
310 - ui.label(
311 - egui::RichText::new(format!(
312 - "{} would hold it, at {}/{}.",
313 - format_cap(proposed),
314 - format_cents(pricing.quote_cents(proposed, interval_enum).0),
315 - match interval_enum {
316 - BillingInterval::Monthly => "mo",
317 - BillingInterval::Annual => "yr",
318 - }
319 - ))
320 - .color(theme::content_muted()),
321 - );
322 - }
323 - }
324 -
325 - ui.add_space(theme::space::hair());
326 - let cap = draw_cap_choice(ui, state, &pricing, interval_enum);
327 - ui.add_space(theme::space::hair());
328 - let loading = state.sync.checkout_loading;
329 - if ui
330 - .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, "Update cap"))
331 - .inner
332 - .clicked()
333 - {
334 - sync.queue_cap_change(cap);
335 - }
336 - }
337 - }
338 - _ => {
339 - if let Some(pricing) = &sync_status.pricing {
340 - let pricing = pricing.clone();
341 - // State the need first: it is why the cap below it says what it
342 - // says, and the app knows it exactly.
343 - let need = synced_need(state);
344 - draw_need(ui, need);
345 - ui.add_space(theme::space::hair());
346 - ui.label(
347 - egui::RichText::new(
348 - "Annual is 2 months free, so fewer Stripe fees, and we pass the savings on.",
349 - )
350 - .weak()
351 - .size(11.0),
352 - );
353 - ui.add_space(theme::space::bound());
354 -
355 - // Named sizes, each carrying its price at the annual rate, since
356 - // annual is the recommendation; the monthly figure is on the
357 - // button below. Replaces a logarithmic slider that asked the user
358 - // to sweep three orders of magnitude to reach a number the app
359 - // could already compute.
360 - let cap_bytes = draw_cap_choice(ui, state, &pricing, BillingInterval::Annual);
361 - ui.add_space(theme::space::bound());
362 -
363 - if state.sync.checkout_loading {
364 - ui.horizontal(|ui| {
365 - ui.spinner();
366 - ui.label(egui::RichText::new("Opening browser...").weak());
367 - });
368 - } else {
369 - let annual =
370 - format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Annual).0);
371 - let monthly =
372 - format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Monthly).0);
373 - ui.horizontal(|ui| {
374 - if widgets::primary_button(
375 - ui,
376 - &format!("Subscribe annual \u{2014} {annual}/yr"),
377 - )
378 - .clicked()
379 - {
380 - state.sync.checkout_loading = true;
381 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
382 - sync.subscribe(cap_bytes, BillingInterval::Annual);
383 - }
384 - if widgets::secondary_button(ui, &format!("Monthly \u{2014} {monthly}/mo"))
385 - .clicked()
386 - {
387 - state.sync.checkout_loading = true;
388 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
389 - sync.subscribe(cap_bytes, BillingInterval::Monthly);
390 - }
391 - });
392 - }
393 - } else {
394 - ui.label(egui::RichText::new("Loading pricing...").weak());
395 - }
396 - }
397 - }
398 - }
399 -
400 - /// Draw a fallback panel when no SyncManager is available (no embedded API key in dev builds).
401 - pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) {
402 - let mut open = state.sync.show_panel;
403 - widgets::modal_window_with_open(
404 - ctx,
405 - "Cloud Sync",
406 - Some(&mut open),
407 - false,
408 - Some(380.0),
409 - |ui| {
410 - ui.label("Cloud sync is unavailable.");
411 - ui.add_space(theme::space::peer());
412 - ui.label(
413 - egui::RichText::new("Open a vault to enable sync.")
414 - .small()
415 - .weak(),
416 - );
417 - },
418 - );
419 - state.sync.show_panel = open;
420 - }
421 -
422 - /// Disconnected state: invite user to connect.
423 - fn draw_disconnected(ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager) {
424 - ui.label("Connect your audiofiles vault to Makenot.work for cross-device sync.");
425 - ui.add_space(theme::space::peer());
426 - ui.label(
427 - egui::RichText::new("Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.")
428 - .small()
429 - .weak(),
430 - );
431 - ui.add_space(theme::space::group());
432 - if ui.button("Connect").clicked() {
433 - match sync.start_auth() {
434 - Ok(auth_url) => {
435 - #[cfg(target_os = "macos")]
436 - let _ = std::process::Command::new("open").arg(&auth_url).spawn();
437 - #[cfg(target_os = "linux")]
438 - let _ = std::process::Command::new("xdg-open")
439 - .arg(&auth_url)
440 - .spawn();
441 - #[cfg(target_os = "windows")]
442 - let _ = std::process::Command::new("cmd")
443 - .args(["/c", "start", &auth_url])
444 - .spawn();
445 - state.sync.auth_code_input.clear();
446 - // Cache for the Authenticating screen's "Copy URL" fallback,
447 - // the browser may have failed to open silently.
448 - state.sync.auth_url = Some(auth_url);
449 - }
450 - Err(e) => {
451 - error!("Failed to start auth: {e}");
452 - state.status = format!("Sync connect failed: {e}");
453 - }
454 - }
455 - }
456 - }
457 -
458 - /// Authenticating state: waiting for OAuth callback, with Cancel + Copy URL
459 - /// escape hatches. Without these the user could be trapped here indefinitely
460 - /// when the browser doesn't open, OAuth fails server-side, or they change
461 - /// their mind, closing the window doesn't move the underlying state.
462 - fn draw_authenticating(ui: &mut egui::Ui, state: &mut BrowserState, sync: &SyncManager) {
463 - ui.horizontal(|ui| {
464 - ui.label("Waiting for authentication in your browser...");
465 - ui.spinner();
466 - });
467 - ui.add_space(theme::space::peer());
468 - ui.label(
469 - egui::RichText::new("The app will update automatically once you sign in.")
470 - .small()
471 - .weak(),
472 - );
473 -
474 - // Copy-URL fallback: the browser may have failed to open silently (wrong
475 - // default browser, headless system, popup blocker on a Tauri host).
476 - if let Some(ref url) = state.sync.auth_url.clone() {
477 - ui.add_space(theme::space::peer());
478 - ui.separator();
479 - ui.add_space(theme::space::bound());
480 - ui.label(
481 - egui::RichText::new("Browser didn't open? Copy this URL into a browser manually.")
482 - .small()
483 - .weak(),
484 - );
485 - ui.add_space(theme::space::hair());
486 - ui.horizontal(|ui| {
487 - // Read-only truncated URL display + Copy button. The URL itself is
488 - // long (OAuth + PKCE + state) so truncation is necessary.
489 - let mut shown = url.clone();
490 - widgets::text_field(
491 - ui,
492 - egui::TextEdit::singleline(&mut shown).desired_width(ui.available_width() - 70.0),
493 - );
494 - if ui.button("Copy").clicked() {
495 - ui.ctx().copy_text(url.clone());
496 - state.status = "Copied auth URL.".to_string();
497 - }
498 - });
499 - }
500 -
Lines truncated