Skip to main content

max / audiofiles

Describe the cloud sync panel Ten shapes: the screen, the four states, the three subscription answers, the cap picker and the exact-figure form. Four of the five state shapes took the body region and handed it back. The screen was a match assigning the region three ways with two early returns around it; read into three bools and two Options, it is one region with guards and loops, and the unavailable case stops being an early return that also has to remember not to draw the error banner. `subscription` was a three-way dispatch and is gone: the read answers exactly one of waiting, running and on offer, so `ready` places all three with loops. **The six `&dyn Sync` headers are gone and nothing in the form was needed for them.** Every declared shape takes the read, so the capability handle stops at `read`. Measured that a `&dyn` header parses and emits anyway, by putting one on a declared shape: the parameter type is a whole `syn::Type` and always has been.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 23:20 UTC
Signed with PGP, not checked
Commit: 41b77506b297ec2471319a3fd8aa67112a04e964
Parent: aaad3e8
2 files changed, +164 insertions, -252 deletions
M Cargo.lock +4 -4
@@ -7594,6 +7594,10 @@
7594 7594 name = "quasi-webview"
7595 7595 version = "0.101.1"
7596 7596
7597 + [[patch.unused]]
7598 + name = "quasi-type"
7599 + version = "0.1.3"
7600 +
7597 7601 [[patch.unused]]
7598 7602 name = "kberg"
7599 7603 version = "0.1.0"
@@ -7605,7 +7609,3 @@
7605 7609 [[patch.unused]]
7606 7610 name = "painhours"
7607 7611 version = "0.1.0"
7608 -
7609 - [[patch.unused]]
7610 - name = "quasi-type"
7611 - version = "0.1.3"
@@ -104,13 +104,11 @@
104 104 //! [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) and the
105 105 //! renderer owns what waiting looks like.
106 106
107 - use quasi_router::layout::{FieldKind, Tone};
108 - use quasi_router::{
109 - Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
110 - Slot,
111 - };
107 + use quasi_declare::declare;
108 + use quasi_router::layout::Tone;
109 + use quasi_router::{Action, Choice, Node, Request, Response, RouteError, Router};
112 110
113 - use super::{State, Status, Subscription, Sync};
111 + use super::{State, Subscription, Sync};
114 112
115 113 /// The region the whole screen answers into.
116 114 const BODY: &str = "sync-body";
@@ -151,7 +149,7 @@
151 149
152 150 /// `GET /sync`
153 151 fn index(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
154 - Ok(screen(state.sync).into())
152 + Ok(showing(state.sync))
155 153 }
156 154
157 155 /// `POST /sync/connect`
@@ -172,7 +170,7 @@
172 170 /// `POST /sync/cancel`
173 171 fn cancel(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
174 172 state.sync.cancel();
175 - Ok(screen(state.sync).into())
173 + Ok(showing(state.sync))
176 174 }
177 175
178 176 /// `POST /sync/encryption`
@@ -183,8 +181,9 @@
183 181 fn encryption(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
184 182 let password = request.payload.get("password").unwrap_or_default();
185 183 if password.is_empty() {
186 - return Ok(Response::from(screen(state.sync))
187 - .toast(Tone::Danger, "A password is needed to encrypt this vault."));
184 + return Ok(
185 + showing(state.sync).toast(Tone::Danger, "A password is needed to encrypt this vault.")
186 + );
188 187 }
189 188 let is_new = matches!(
190 189 state.sync.status().state,
@@ -193,20 +192,20 @@
193 192 }
194 193 );
195 194 state.sync.set_password(password, is_new);
196 - Ok(screen(state.sync).into())
195 + Ok(showing(state.sync))
197 196 }
198 197
199 198 /// `POST /sync/now`
200 199 fn sync_now(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
201 200 state.sync.sync_now();
202 - Ok(screen(state.sync).into())
201 + Ok(showing(state.sync))
203 202 }
204 203
205 204 /// `POST /sync/auto`
206 205 fn auto(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
207 206 let on = !request.payload.get("auto").unwrap_or_default().is_empty();
208 207 state.sync.set_auto(on);
209 - Ok(screen(state.sync).into())
208 + Ok(showing(state.sync))
210 209 }
211 210
212 211 /// `POST /sync/interval`
@@ -221,13 +220,13 @@
221 220 return Err(RouteError::not_found("no such interval"));
222 221 }
223 222 state.sync.set_interval(minutes);
224 - Ok(screen(state.sync).into())
223 + Ok(showing(state.sync))
225 224 }
226 225
227 226 /// `POST /sync/error/clear`
228 227 fn clear_error(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
229 228 state.sync.clear_error();
230 - Ok(screen(state.sync).into())
229 + Ok(showing(state.sync))
231 230 }
232 231
233 232 /// `POST /sync/subscription/refresh`
@@ -236,7 +235,7 @@
236 235 _request: Request,
237 236 ) -> Result<Response, RouteError> {
238 237 state.sync.refresh_subscription();
239 - Ok(screen(state.sync).into())
238 + Ok(showing(state.sync))
240 239 }
241 240
242 241 /// `POST /sync/subscribe`
@@ -249,15 +248,14 @@
249 248 let cap = cap_from(state, &request)?;
250 249 let annual = request.payload.get("cadence") == Some("annual");
251 250 state.sync.subscribe(cap, annual);
252 - Ok(Response::from(screen(state.sync)).toast(Tone::Info, "Opening checkout in your browser."))
251 + Ok(showing(state.sync).toast(Tone::Info, "Opening checkout in your browser."))
253 252 }
254 253
255 254 /// `POST /sync/cap`
256 255 fn cap(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
257 256 let cap = cap_from(state, &request)?;
258 257 state.sync.queue_cap_change(cap);
259 - Ok(Response::from(screen(state.sync))
260 - .toast(Tone::Success, "The cap changes at your next renewal."))
258 + Ok(showing(state.sync).toast(Tone::Success, "The cap changes at your next renewal."))
261 259 }
262 260
263 261 /// The cap a request asked for, in bytes, refused if it is outside what is sold.
@@ -285,262 +283,229 @@
285 283 /// `POST /sync/disconnect`
286 284 fn disconnect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
287 285 state.sync.disconnect();
288 - Ok(screen(state.sync).into())
286 + Ok(showing(state.sync))
289 287 }
290 288
291 - /// The screen, which is a different screen per state.
292 - ///
293 - /// One route answering four shapes rather than four routes: the state is not an
294 - /// address, and a user cannot navigate to `Authenticating` — they arrive there
295 - /// because something happened. Four addresses would be four places you could
296 - /// bookmark into a lie.
297 - fn screen(sync: &dyn Sync) -> Screen {
298 - let status = sync.status();
299 - // Live, which is the whole of what the finding in this module's header
300 - // asked for. The state moves without anyone acting: an OAuth callback lands
301 - // in another process and `Authenticating` becomes `NeedsEncryption`, and the
302 - // pending count falls while a sync runs. The description says the contents
303 - // move and says nothing about how often to look; the cadence is
304 - // `quasi_immediate::CADENCE`, and the host no longer owns a timer.
305 - let mut body = Slot::new(BODY, RegionKind::Pane)
306 - .live()
307 - .with(Node::page("Cloud Sync"))
308 - // The way off, for `settings::screen`'s reason: this screen replaces
309 - // the shell's and nothing described an exit from it.
310 - .with(Node::Act(Act::new("Close", Action::back())));
311 -
312 - // Nothing to offer when there is nothing to offer it against. See
313 - // [`Sync::available`]: an unavailable manager is not a disconnected one, and
314 - // the flip found the described screen offering a `Connect` that refuses and
315 - // a `Dismiss` for an error `clear_error` cannot clear. The shipped panel
316 - // answered this state with a whole second window saying the same two
317 - // sentences and drawing no controls.
318 - if !sync.available() {
319 - return Screen::sidebar_content("Cloud Sync").with(
320 - body.with(Node::text("Cloud sync is unavailable."))
321 - .with(Node::text("Open a vault to enable sync.")),
322 - );
323 - }
324 -
325 - body = match status.state {
326 - State::Disconnected => disconnected(body),
327 - State::Authenticating => authenticating(body),
328 - State::NeedsEncryption { has_server_key } => needs_encryption(body, has_server_key),
329 - State::Ready | State::Syncing => ready(body, &status, sync),
330 - };
331 -
332 - // The error banner, on every state, because a failure can arrive in any of
333 - // them. `Node::Notice` carries the tone and the text; what to do about it is
334 - // two controls, and Retry is only offered where retrying means anything.
335 - if let Some(error) = &status.last_error {
336 - body = body.with(Node::banner(Tone::Danger, error.clone()));
337 - if matches!(status.state, State::Ready | State::Syncing) {
338 - body = body.with(Node::Act(Act::new("Retry", Action::post("/sync/now"))));
339 - }
340 - body = body.with(Node::Act(Act::new(
341 - "Dismiss",
342 - Action::post("/sync/error/clear"),
343 - )));
344 - }
345 -
346 - Screen::sidebar_content("Cloud Sync").with(body)
289 + /// The screen, read and then described.
290 + pub(super) fn showing(sync: &dyn Sync) -> Response {
291 + Response::from(screen(&read(sync)))
347 292 }
348 293
349 - fn disconnected(body: Slot) -> Slot {
350 - body.with(Node::text(
351 - "Connect your audiofiles vault to Makenot.work for cross-device sync.",
352 - ))
353 - .with(Node::text(
354 - "Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.",
355 - ))
356 - .with(Node::Act(Act::new("Connect", Action::post("/sync/connect"))))
357 - }
358 -
359 - /// Waiting on a browser, with a way out.
360 - ///
361 - /// The spinner needs no vocabulary: [`Readiness::Pending`] is what a region
362 - /// waiting on something says, and every renderer already answers it. That is one
363 - /// place this screen expected a finding and did not get one.
364 - fn authenticating(body: Slot) -> Slot {
365 - body.with(Node::text("Waiting for authentication in your browser..."))
366 - .with(Node::text(
367 - "The app will update automatically once you sign in.",
368 - ))
369 - .with(Node::Act(Act::new("Cancel", Action::post("/sync/cancel"))))
294 + /// What the panel draws, read off the manager.
295 + struct Cloud {
296 + /// Whether there is anything to offer this against.
297 + ///
298 + /// Nothing to offer when there is nothing to offer it against. See
299 + /// [`Sync::available`]: an unavailable manager is not a disconnected one,
300 + /// and the flip found the described screen offering a `Connect` that refuses
301 + /// and a `Dismiss` for an error `clear_error` cannot clear. The shipped
302 + /// panel answered this state with a whole second window saying the same two
303 + /// sentences and drawing no controls.
304 + away: bool,
305 + /// Whether there is nothing connected yet.
306 + disconnected: bool,
307 + /// Whether a browser is being waited on.
308 + authenticating: bool,
309 + /// The password step, while it is the step.
310 + encryption: Option<Encryption>,
311 + /// A connected panel, while it is connected.
312 + ready: Option<Ready>,
313 + /// What went wrong, if anything did.
314 + ///
315 + /// On every state that has one, because a failure can arrive in any of them,
316 + /// and on none of them when the manager is away: there is nothing there to
317 + /// have failed.
318 + error: Option<Failure>,
370 319 }
371 320
372 321 /// The password that encrypts this vault.
373 322 ///
374 - /// [`FieldKind::Secret`] and nothing else: the description will not carry the
375 - /// typed value, which is `39057019`, so the runtime's buffer is the only place
376 - /// it has ever lived. Whether this is a new password or an existing one changes
377 - /// only what is said, which is why `has_server_key` reaches the prose and not
378 - /// the shape.
379 - fn needs_encryption(body: Slot, has_server_key: bool) -> Slot {
380 - let says = if has_server_key {
381 - "This vault is already encrypted. Enter its password to unlock it here."
382 - } else {
383 - "Choose a password. It encrypts everything before it leaves this machine, and it cannot be recovered."
384 - };
385 - body.with(Node::text(says)).with(Node::Form {
386 - fields: vec![
387 - Field::new(FieldKind::Secret, "password", "Password")
388 - .required()
389 - .hint(if has_server_key {
390 - "The password this vault was encrypted with."
391 - } else {
392 - "Nobody can reset this for you."
393 - }),
394 - ],
395 - submit: if has_server_key {
396 - "Unlock"
397 - } else {
398 - "Set password"
399 - }
400 - .to_owned(),
401 - action: Action::post("/sync/encryption"),
402 - })
323 + /// Whether this is a new password or an existing one changes only what is said,
324 + /// which is why `has_server_key` reaches the prose and not the shape.
325 + struct Encryption {
326 + /// What the step says.
327 + said: &'static str,
328 + /// What the box says under itself.
329 + hint: &'static str,
330 + /// What the button reads.
331 + submit: &'static str,
403 332 }
404 333
405 334 /// Connected, and what it is doing.
406 - fn ready(body: Slot, status: &Status, sync: &dyn Sync) -> Slot {
407 - let syncing = matches!(status.state, State::Syncing);
408 - let mut body = body.with(Node::text(if syncing { "Syncing..." } else { "Connected" }));
409 -
410 - if let Some(last) = &status.last_sync_at {
411 - body = body.with(Node::text(format!("Last sync: {last}")));
412 - }
413 - if status.pending_changes > 0 {
414 - // A count, not a proportion: nothing knows the total, and a `Meter`
415 - // handed a made-up denominator would draw a bar that means nothing.
416 - body = body.with(Node::Figure(quasi_router::Figure::new(
417 - status.pending_changes.to_string(),
418 - "pending changes",
419 - )));
420 - }
421 -
422 - let mut now = Act::new("Sync now", Action::post("/sync/now"));
423 - if syncing {
424 - // Present, visible and not answering, which is what a control that is
425 - // already running should be. The shipped panel says the same thing with
426 - // `add_enabled(!syncing, ...)`.
427 - now = now.disabled();
428 - }
429 -
430 - body.with(Node::Act(now))
431 - .with(Node::section("Auto-sync"))
432 - .with(Node::Field(Box::new(
433 - Field::new(FieldKind::Checkbox, "auto", "Sync on a schedule")
434 - .value(if status.auto_sync_enabled { "on" } else { "" })
435 - .writes(Action::post("/sync/auto")),
436 - )))
437 - // A label at last: the "Auto-sync" heading above names the checkbox, so
438 - // as a bare strip this control asked its question without ever saying
439 - // what it was. A field cannot be unlabelled.
440 - .with(Node::Field(Box::new(
441 - Field::radio(
442 - INTERVAL,
443 - "Interval",
444 - INTERVALS
445 - .iter()
446 - .map(|minutes| Choice::new(minutes.to_string(), format!("{minutes} min")))
447 - .collect(),
448 - )
449 - .value(status.sync_interval_minutes.to_string())
450 - .writes(Action::post("/sync/interval")),
451 - )))
452 - .with(Node::section("Audio file sync"))
453 - .with(subscription(sync))
454 - .with(Node::Act(
455 - Act::new("Disconnect", Action::post("/sync/disconnect"))
456 - .tone(Tone::Danger)
457 - .confirm("Disconnect this vault from cloud sync?"),
458 - ))
335 + struct Ready {
336 + /// Syncing, or merely connected.
337 + said: &'static str,
338 + /// Whether a sync is running, which is what deadens the control.
339 + syncing: bool,
340 + /// When the last sync finished, as the app formats it.
341 + last: Option<String>,
342 + /// How many local changes have not gone up, while any have not.
343 + ///
344 + /// A count, not a proportion: nothing knows the total, and a meter handed a
345 + /// made-up denominator would draw a bar that means nothing.
346 + pending: Option<String>,
347 + /// Whether the scheduler is running.
348 + auto: &'static str,
349 + /// The cadences, and which is chosen.
350 + intervals: Vec<Choice>,
351 + every: String,
352 + /// What is bought, or what may be. Exactly one of the three.
353 + waiting: Option<&'static str>,
354 + running: Option<Running>,
355 + offer: Option<Offer>,
459 356 }
460 357
461 - /// What is bought, or what may be.
462 - ///
463 - /// Three shapes: not fetched yet, subscribed, or on offer. The first is a region
464 - /// rather than a sentence, because "not fetched yet" is exactly what
465 - /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) says and the
466 - /// renderer already knows how to draw waiting.
467 - fn subscription(sync: &dyn Sync) -> Node {
468 - let Some(pricing) = sync.pricing() else {
469 - return waiting("Loading pricing...");
470 - };
471 - match sync.subscription() {
472 - None => waiting("Checking subscription..."),
473 - Some(sub) if sub.active => subscribed(sync, &sub, &pricing),
474 - Some(_) => on_offer(sync, &pricing),
475 - }
476 - }
477 -
478 - /// A region that is waiting on something.
479 - ///
480 - /// The whole of what the shipped panel spends two `Instant` fields, two
481 - /// thirty-second timeouts and a spinner on. How long to wait and what to draw
482 - /// while waiting are the renderer's, which is why neither is here.
483 - fn waiting(says: &str) -> Node {
484 - Node::Region(
485 - Slot::new("subscription", RegionKind::Pane)
486 - .pending()
487 - .with(Node::text(says))
488 - .with(Node::Act(Act::new(
489 - "Retry",
490 - Action::post("/sync/subscription/refresh"),
491 - ))),
492 - )
358 + /// What went wrong, and what can be done about it.
359 + struct Failure {
360 + /// What it says.
361 + said: String,
362 + /// Whether retrying means anything, which it does only once connected.
363 + retryable: bool,
493 364 }
494 365
495 366 /// A running subscription: what it holds, how full it is, and how to change it.
496 - fn subscribed(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Node {
497 - let mut slot = Slot::new("subscription", RegionKind::Pane).with(Node::text(format!(
498 - "Subscribed: {} ({})",
499 - gib_of(sub.limit_bytes),
500 - sub.interval
501 - )));
367 + struct Running {
368 + /// What is bought, and how often it is paid for.
369 + said: String,
370 + /// How full it is, where a cap was bought at all.
371 + gauge: Option<Gauge>,
372 + /// What to say about a cap that is filling, if anything.
373 + warning: Option<Warning>,
374 + /// A cap change already queued for the next renewal.
375 + queued: Option<String>,
376 + /// The cap control, defaulted to what is already bought.
377 + cap: Cap,
378 + }
502 379
503 - if sub.limit_bytes > 0 {
504 - // A real proportion, so a real `Meter`: used against bought, both known.
505 - // Counted in GiB rather than bytes because the bar is read by a person
506 - // and `Meter` takes two `u32`s.
507 - slot = slot.with(Node::Meter(
508 - quasi_router::Meter::new(gib_count(sub.used_bytes), gib_count(sub.limit_bytes).max(1))
509 - .label("GiB used")
510 - // At ninety percent, not past it: this is a cap that stops syncing
511 - // when it fills, and the point of saying so is to say it before
512 - // that happens.
513 - .tone(if nearly_full(sub) {
514 - Tone::Warning
515 - } else {
516 - Tone::Neutral
517 - }),
518 - ));
380 + /// A proportion of a bought cap.
381 + ///
382 + /// Counted in GiB rather than bytes because the bar is read by a person and a
383 + /// meter takes two `u32`s.
384 + struct Gauge {
385 + used: u32,
386 + limit: u32,
387 + /// At ninety percent, not past it: this is a cap that stops syncing when it
388 + /// fills, and the point of saying so is to say it before that happens.
389 + tone: Tone,
390 + }
391 +
392 + /// What a filling cap says in words.
393 + ///
394 + /// Say it in words as well as in the bar, and say what happens next. A meter
395 + /// that has gone amber reports a quantity; the user needs the consequence, which
396 + /// is that uploads stop and metadata sync carries on.
397 + struct Warning {
398 + said: String,
399 + tone: Tone,
400 + }
401 +
402 + /// No subscription yet: the screen proposes a cap and says what it costs.
403 + struct Offer {
404 + /// The need, first, because it is the reason the rest of the screen says
405 + /// what it says.
406 + need: String,
407 + /// What is proposed, where there is anything to size it against.
408 + proposed: Option<String>,
409 + /// The cap control.
410 + cap: Cap,
411 + }
412 +
413 + /// The cap, as the two controls that set it.
414 + struct Cap {
415 + /// A few named sizes, each carrying what it costs.
416 + sizes: Vec<Choice>,
417 + /// The one chosen, in whole GiB.
418 + chosen: String,
419 + /// The bounds the exact figure is held to.
420 + min: String,
421 + max: String,
422 + /// What the exact box says under itself.
423 + hint: String,
424 + }
425 +
426 + /// What the panel draws, read off the manager.
427 + fn read(sync: &dyn Sync) -> Cloud {
428 + if !sync.available() {
429 + return Cloud {
430 + away: true,
431 + disconnected: false,
432 + authenticating: false,
433 + encryption: None,
434 + ready: None,
435 + error: None,
436 + };
519 437 }
520 -
521 - // Say it in words as well as in the bar, and say what happens next. A meter
522 - // that has gone amber reports a quantity; the user needs the consequence,
523 - // which is that uploads stop and metadata sync carries on. Without this the
524 - // first news of a full cap is a failed upload - the 402 from
525 - // `routes/synckit/blobs.rs`, which the user meets as a sync that broke.
526 - if let Some(warning) = cap_warning(sync, sub, pricing) {
527 - slot = slot.with(Node::banner(
528 - if sub.used_bytes >= sub.limit_bytes {
529 - Tone::Danger
530 - } else {
531 - Tone::Warning
532 - },
533 - warning,
534 - ));
438 + let status = sync.status();
439 + let connected = matches!(status.state, State::Ready | State::Syncing);
440 + Cloud {
441 + away: false,
442 + disconnected: matches!(status.state, State::Disconnected),
443 + authenticating: matches!(status.state, State::Authenticating),
444 + encryption: match status.state {
445 + State::NeedsEncryption { has_server_key } => Some(encrypting(has_server_key)),
446 + _ => None,
Lines truncated