Skip to main content

max / audiofiles

Describe the settings Storage section Five shapes: the section, the library list, the inline rename, the maintenance passes and the Add Library form. Wave 15 had already undone this file's container-passing so the settings screen could be declared, which left it a straight reading. A library row carries at most one extra line, and that is structural rather than lucky: an offline row says its last-known path and the open one says what the scan counted, and no row is both. The read says so with an Option, which settles the `Row::meta` question 8e7011e4 raised for the converted trees. `settings::showing` reaches the section through the read, the way it already reaches trash's.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:44 UTC
Signed with PGP, not checked
Commit: 20c65a66d6aac7c4a2e0df044f515102fd63ea90
Parent: 0f7c080
2 files changed, +273 insertions, -200 deletions
@@ -258,7 +258,7 @@
258 258 pub(super) fn showing(state: &Panels<'_>) -> Result<Response, RouteError> {
259 259 Ok(Response::from(screen(
260 260 &read(state)?,
261 - super::storage::section(state),
261 + super::storage::section(&super::storage::read(state)),
262 262 super::trash::section(&super::trash::read(state)),
263 263 super::licence::section(&super::licence::read(state)),
264 264 super::advanced::section(state.advanced.mirror().as_ref()),
@@ -78,11 +78,9 @@
78 78 //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
79 79 //! [`Row::activate`]: quasi_router::Row::activate
80 80
81 - use quasi_router::layout::{FieldKind, Tone};
82 - use quasi_router::{
83 - Act, Action, Choice, Field, Locating, Node, Outcome, Request, Response, RouteError, Router,
84 - Row, Tag,
85 - };
81 + use quasi_declare::declare;
82 + use quasi_router::layout::Tone;
83 + use quasi_router::{Action, Choice, Locating, Outcome, Request, Response, RouteError, Router, Tag};
86 84
87 85 use super::Panels;
88 86
@@ -301,125 +299,62 @@
301 299 .map_err(|_| RouteError::not_found("that is not a row"))
302 300 }
303 301
304 - /// The whole section, spliced into the settings body.
305 - ///
306 - /// Nodes rather than a `Slot` handed in and handed back, which is the shape a
307 - /// declaration is refused and the shape the settings screen cannot splice now
308 - /// that it is one. The three sections beside this one made the same move.
309 - pub(super) fn section(state: &Panels<'_>) -> Vec<Node> {
310 - let mut body = vec![
311 - Node::section("Storage"),
312 - Node::text(
313 - "Each library is an independent sample collection with its own database and files. A library can contain multiple vaults (top-level browse buckets).",
314 - ),
315 - libraries(state),
316 - ];
317 -
318 - if let Some(at) = state.storage.renaming() {
319 - body.push(rename_form(at, state));
320 - }
321 -
322 - body.extend(maintenance(state));
323 -
324 - if state.storage.loose_files() {
325 - body.push(Node::Text {
326 - text: "This library uses loose-files mode. Samples are referenced in place, not duplicated.".to_owned(),
327 - tone: Tone::Warning,
328 - });
329 - }
330 -
331 - body.extend(add_library(state));
332 - body
302 + /// What the Storage section draws, read off the app.
303 + pub(super) struct Storage {
304 + /// The libraries, one row each.
305 + libraries: Vec<Library>,
306 + /// The inline rename, against whichever row is being renamed.
307 + renaming: Option<Renaming>,
308 + /// Scan, and the three maintenance passes over the open library.
309 + maintenance: Maintenance,
310 + /// Whether the open library references samples in place.
311 + loose: bool,
312 + /// The Add Library form.
313 + draft: Draft,
333 314 }
334 315
335 - /// The libraries, one row each.
336 - fn libraries(state: &Panels<'_>) -> Node {
337 - let all = state.storage.libraries();
338 - let scan = state.storage.scan();
339 - let interrupting = state.storage.interrupting();
340 - let mut rows = Vec::with_capacity(all.len());
316 + /// One library, as the row it is.
317 + struct Library {
318 + /// What it is called.
319 + name: String,
320 + /// Its directory, with the home prefix collapsed the way the reader saw it.
321 + shown: String,
322 + /// What the row is badged with, where it is badged with anything.
323 + badge: Option<&'static str>,
324 + /// The one extra line the row carries.
325 + ///
326 + /// At most one, and structurally rather than by luck: an offline row says
327 + /// its last-known path and the open row says what the scan counted, and no
328 + /// row is both. The offline row's path was a hover in the shipped panel; a
329 + /// hover is a host's, so it is said outright and a terminal or a screen
330 + /// reader gets to keep it.
331 + meta: Option<String>,
332 + /// Where it sits, which is what every one of its addresses is built from.
333 + at: usize,
334 + /// Switching to it, where it can be switched to.
335 + open: Option<Open>,
336 + /// Whether it can be pointed at a new directory.
337 + locatable: bool,
338 + /// Whether it can be forgotten.
339 + forgettable: bool,
340 + }
341 341
342 - for (at, entry) in all.iter().enumerate() {
343 - // The offline row's badge carried the last-known path on hover in the
344 - // shipped panel. A hover is a host's; the path is said outright here,
345 - // which is what a terminal or a screen reader gets to keep.
346 - let mut row = Row::new(&entry.name).secondary(entry.shown.clone());
347 - if entry.active {
348 - row = row.token(Tag::badge("active"));
349 - } else if !entry.reachable {
350 - row = row
351 - .token(Tag::badge("offline"))
352 - .meta(format!("Last known path: {}", entry.path));
353 - }
354 -
355 - // Only the open library has been counted: the scan reads the database
356 - // that is open, and the shipped rows were path-only for the rest.
357 - if entry.active
358 - && let Some(scan) = scan
359 - {
360 - row = row.meta(format!(
361 - "{} samples \u{b7} {}",
362 - scan.samples,
363 - bytes(scan.total_bytes),
364 - ));
365 - }
366 -
367 - let switchable = !entry.active && entry.reachable;
368 - if switchable {
369 - let mut act = Act::new("Open", Action::post(format!("/settings/storage/open/{at}")));
370 - if interrupting {
371 - act = act.confirm(INTERRUPT);
372 - } else {
373 - // See the module header: the click is only offered where it
374 - // cannot skip an asking.
375 - row = row.activate(Action::post(format!("/settings/storage/open/{at}")));
376 - }
377 - row = row.act(act);
378 - }
379 -
380 - row = row.act(Act::new(
381 - "Rename",
382 - Action::post(format!("/settings/storage/rename/{at}")),
383 - ));
384 -
385 - if !entry.active && !entry.reachable {
386 - row = row.act(Act::new(
387 - "Locate",
388 - Action::post(format!("/settings/storage/locate/{at}")),
389 - ));
390 - }
391 -
392 - if !entry.active {
393 - row = row.act(
394 - Act::new(
395 - "Remove",
396 - Action::post(format!("/settings/storage/forget/{at}")),
397 - )
398 - .tone(Tone::Danger)
399 - .confirm(FORGET),
400 - );
401 - }
402 -
403 - rows.push(row);
404 - }
405 -
406 - Node::List { rows, more: None }
342 + /// Switching to a library that is not the open one.
343 + struct Open {
344 + /// Whether the switch would interrupt a running job, which is what makes it
345 + /// ask first.
346 + ///
347 + /// The press is offered on the row itself only where it cannot skip an
348 + /// asking. See the module header.
349 + interrupting: bool,
407 350 }
408 351
409 352 /// The inline rename, against whichever row is being renamed.
410 - fn rename_form(at: usize, state: &Panels<'_>) -> Node {
411 - let current = state
412 - .storage
413 - .libraries()
414 - .get(at)
415 - .map(|entry| entry.name.clone())
416 - .unwrap_or_default();
417 -
418 - Node::Form {
419 - fields: vec![Field::new(FieldKind::Text, NAME, "New name").value(current)],
420 - submit: "Save".to_owned(),
421 - action: Action::post(format!("/settings/storage/rename/{at}/save")),
422 - }
353 + struct Renaming {
354 + /// Where that row sits.
355 + at: usize,
356 + /// What it is called now.
357 + current: String,
423 358 }
424 359
425 360 /// Scan, and the three maintenance passes over the open library.
@@ -428,145 +363,294 @@
428 363 /// swapping its label, which is the shipped busy state minus the spinner: a
429 364 /// spinner is a renderer's way of drawing "working", and every host has one or
430 365 /// has something better.
431 - fn maintenance(state: &Panels<'_>) -> Vec<Node> {
432 - let scanning = state.storage.scanning();
433 - let mut scan_act = Act::new(
434 - if scanning { "Scanning..." } else { "Scan" },
435 - Action::post("/settings/storage/scan"),
436 - );
437 - if scanning {
438 - scan_act = scan_act.disabled();
439 - }
440 - let mut body = vec![Node::Act(scan_act)];
366 + struct Maintenance {
367 + /// What the scan control reads.
368 + scan: &'static str,
369 + /// Whether a scan is running.
370 + scanning: bool,
371 + /// What the last scan counted, where one has run.
372 + counted: Option<Counted>,
373 + /// What the backfill control reads.
374 + backfill: &'static str,
375 + /// Whether the feature backfill is running.
376 + backfilling: bool,
377 + /// Whether the maintenance worker is busy, which is what the integrity check
378 + /// shares its flag with.
379 + busy: bool,
380 + }
441 381
442 - if let Some(scan) = state.storage.scan() {
443 - body.push(Node::text(format!(
444 - "{} samples, {} total, {} database",
445 - scan.samples,
446 - bytes(scan.total_bytes),
447 - bytes(scan.db_bytes),
448 - )));
449 - let (age, stale) = scan_age(scan.age_secs);
450 - body.push(Node::Text {
451 - text: age,
452 - tone: if stale { Tone::Warning } else { Tone::Neutral },
453 - });
454 - }
455 -
456 - body.push(Node::text(
457 - "Free disk by deleting samples no longer referenced anywhere in the library. Local-only: other synced devices keep their own copies.",
458 - ));
459 - body.push(Node::Act(Act::new(
460 - "Cleanup orphans",
461 - Action::post("/settings/storage/orphans"),
462 - )));
463 -
464 - let backfilling = state.storage.backfilling();
465 - let mut backfill = Act::new(
466 - if backfilling {
467 - "Backfilling audio features..."
468 - } else {
469 - "Backfill audio features"
470 - },
471 - Action::post("/settings/storage/backfill"),
472 - );
473 - if backfilling {
474 - backfill = backfill.disabled();
475 - }
476 - body.push(Node::text(
477 - "Compute the audio feature data used by tag suggestions for samples that don't have it yet. Runs in the background: keep working; it yields to any analysis you start and resumes later.",
478 - ));
479 - body.push(Node::Act(backfill));
480 -
481 - let mut integrity = Act::new(
482 - "Verify library integrity",
483 - Action::post("/settings/storage/verify"),
484 - );
485 - if state.storage.busy() {
486 - integrity = integrity.disabled();
487 - }
488 - body.push(Node::text(
489 - "Re-hash every stored sample and confirm its bytes still match its content address. Catches silent on-disk corruption. Runs in the background: the result appears in the status line.",
490 - ));
491 - body.push(Node::Act(integrity));
492 - body
382 + /// What the last scan counted, and how fresh the numbers are.
383 + struct Counted {
384 + /// The three totals.
385 + totals: String,
386 + /// How long ago it ran.
387 + age: String,
388 + /// Whether that is long enough to say so.
389 + tone: Tone,
493 390 }
494 391
495 392 /// The Add Library form: a name, a folder, and how samples are stored.
496 - fn add_library(state: &Panels<'_>) -> Vec<Node> {
497 - let draft = state.storage.draft();
393 + struct Draft {
394 + /// What the new library is to be called.
395 + name: String,
396 + /// The complaint, where a folder is chosen and the name is empty.
397 + ///
398 + /// The error the shipped form left unexplained: Create New disabled itself
399 + /// and said nothing about why.
400 + error: Option<&'static str>,
401 + /// The folder the host picked, where one was picked.
402 + folder: Option<String>,
403 + /// Which storage style is chosen.
404 + style: &'static str,
405 + /// Whether that style is the one that breaks if the originals move.
406 + loose: bool,
407 + /// Whether the form has enough to act on.
408 + ready: bool,
409 + /// Whether it has anything in it to discard.
410 + started: bool,
411 + }
498 412
499 - // The error the shipped form left unexplained: a folder chosen and no name,
500 - // where Create New disabled itself and said nothing about why.
501 - let mut name = Field::new(FieldKind::Text, "create_name", "Name")
502 - .value(draft.name.clone())
503 - .writes(Action::post("/settings/storage/draft/name"));
504 - name.required = true;
505 - if draft.folder.is_some() && draft.name.trim().is_empty() {
506 - name = name.error("A library needs a name.");
413 + /// What the section draws, read off the app.
414 + pub(super) fn read(state: &Panels<'_>) -> Storage {
415 + let all = state.storage.libraries();
416 + let scan = state.storage.scan();
417 + let interrupting = state.storage.interrupting();
418 + let draft = state.storage.draft();
419 + let scanning = state.storage.scanning();
420 + let backfilling = state.storage.backfilling();
421 + Storage {
422 + libraries: all
423 + .iter()
424 + .enumerate()
425 + .map(|(at, entry)| Library {
426 + name: entry.name.clone(),
427 + shown: entry.shown.clone(),
428 + badge: if entry.active {
429 + Some("active")
430 + } else if entry.reachable {
431 + None
432 + } else {
433 + Some("offline")
434 + },
435 + // Only the open library has been counted: the scan reads the
436 + // database that is open, and the shipped rows were path-only
437 + // for the rest.
438 + meta: match (entry.active, entry.reachable, scan) {
439 + (true, _, Some(scan)) => Some(format!(
440 + "{} samples \u{b7} {}",
441 + scan.samples,
442 + bytes(scan.total_bytes),
443 + )),
444 + (false, false, _) => Some(format!("Last known path: {}", entry.path)),
445 + _ => None,
446 + },
447 + at,
448 + open: (!entry.active && entry.reachable).then_some(Open { interrupting }),
449 + locatable: !entry.active && !entry.reachable,
450 + forgettable: !entry.active,
451 + })
452 + .collect(),
453 + renaming: state.storage.renaming().map(|at| Renaming {
454 + at,
455 + current: all
456 + .get(at)
457 + .map(|entry| entry.name.clone())
458 + .unwrap_or_default(),
459 + }),
460 + maintenance: Maintenance {
461 + scan: if scanning { "Scanning..." } else { "Scan" },
462 + scanning,
463 + counted: scan.map(|scan| {
464 + let (age, stale) = scan_age(scan.age_secs);
465 + Counted {
466 + totals: format!(
467 + "{} samples, {} total, {} database",
468 + scan.samples,
469 + bytes(scan.total_bytes),
470 + bytes(scan.db_bytes),
471 + ),
472 + age,
473 + tone: if stale { Tone::Warning } else { Tone::Neutral },
474 + }
475 + }),
476 + backfill: if backfilling {
477 + "Backfilling audio features..."
478 + } else {
479 + "Backfill audio features"
480 + },
481 + backfilling,
482 + busy: state.storage.busy(),
483 + },
484 + loose: state.storage.loose_files(),
485 + draft: Draft {
486 + error: (draft.folder.is_some() && draft.name.trim().is_empty())
487 + .then_some("A library needs a name."),
488 + style: if draft.reference_in_place {
489 + "reference"
490 + } else {
491 + "copy"
492 + },
493 + loose: draft.reference_in_place,
494 + ready: draft.ready(),
495 + started: draft.started(),
496 + name: draft.name,
497 + folder: draft.folder,
498 + },
499 + }
500 + }
501 +
502 + declare! {
503 + /// The whole section, spliced into the settings body.
504 + ///
505 + /// Nodes rather than a `Slot` handed in and handed back, which is the shape
506 + /// a declaration is refused and the shape the settings screen cannot splice
507 + /// now that it is one. The three sections beside this one made the same
508 + /// move.
509 + pub(super) shape section(storage: &Storage) -> Vec<Node>;
510 +
511 + section "Storage";
512 + text "Each library is an independent sample collection with its own \
513 + database and files. A library can contain multiple vaults (top-level \
514 + browse buckets).";
515 + include libraries(storage);
516 +
517 + for renaming in storage.renaming.iter() {
518 + include rename_form(renaming);
507 519 }
508 520
509 - let mut body = vec![
510 - Node::section("Add Library"),
511 - Node::Field(Box::new(name)),
512 - Node::Act(Act::new(
513 - "Choose folder...",
514 - Action::post("/settings/storage/folder"),
515 - )),
516 - ];
521 + extend maintenance(&storage.maintenance);
517 522
518 - if let Some(folder) = &draft.folder {
519 - body.push(Node::text(folder.clone()));
523 + toned "This library uses loose-files mode. Samples are referenced in place, \
524 + not duplicated." Tone::Warning when storage.loose;
525 +
526 + extend add_library(&storage.draft);
527 + }
528 +
529 + declare! {
530 + /// The libraries, one row each.
531 + shape libraries(storage: &Storage) -> Node;
532 +
533 + list {
534 + for library in storage.libraries.iter() {
535 + row &library.name {
536 + secondary &library.shown;
537 + for &badge in library.badge.iter() {
538 + token Tag::badge(badge);
539 + }
540 + for line in library.meta.iter() {
541 + meta line;
542 + }
543 +
544 + for open in library.open.iter() {
545 + // See the module header: the click is only offered where it
546 + // cannot skip an asking.
547 + activate to post "/settings/storage/open/{library.at}"
548 + unless open.interrupting;
549 + act "Open" to post "/settings/storage/open/{library.at}" {
550 + confirm INTERRUPT when open.interrupting;
551 + }
552 + }
553 +
554 + act "Rename" to post "/settings/storage/rename/{library.at}";
555 + act "Locate" to post "/settings/storage/locate/{library.at}"
556 + when library.locatable;
557 + act "Remove" to post "/settings/storage/forget/{library.at}"
558 + when library.forgettable {
559 + tone Danger;
560 + confirm FORGET;
561 + }
562 + }
563 + }
564 + }
565 + }
566 +
567 + declare! {
568 + /// The inline rename, against whichever row is being renamed.
569 + shape rename_form(renaming: &Renaming) -> Node;
570 +
571 + form post "/settings/storage/rename/{renaming.at}/save" {
572 + submit "Save";
573 + field Text NAME "New name" {
574 + value &renaming.current;
575 + }
576 + }
577 + }
578 +
579 + declare! {
580 + /// Scan, and the three maintenance passes over the open library. See
581 + /// [`Maintenance`].
582 + shape maintenance(maintenance: &Maintenance) -> Vec<Node>;
583 +
584 + act maintenance.scan to post "/settings/storage/scan" {
585 + disabled when maintenance.scanning;
586 + }
587 + for counted in maintenance.counted.iter() {
588 + text &counted.totals;
589 + toned &counted.age counted.tone;
590 + }
591 +
592 + text "Free disk by deleting samples no longer referenced anywhere in the \
Lines truncated