//! The settings window's Storage section, described: the libraries on this //! machine, what they hold, and the four things maintenance can do to the one //! that is open. //! //! The largest thing the settings flip left out. `draw_storage_section` was 430 //! of `ui/settings_panel.rs`'s 1,182 lines and went with the file, under //! `da48cb6d`: a section goes away at the flip and comes back when it is //! described. This is that. //! //! # The refusal it was carrying, counted rather than restated //! //! [`settings`](super::settings)'s header ruled this section out as "library //! paths, reachability, relocation: the filesystem", and the flip's own task //! said to count what was actually missing before repeating that. Counted, it //! was three things and two of them had already been answered elsewhere: //! //! - **Asking for a place** — Locate on an offline row, and Choose folder on the //! Add Library form. `ec92f9cb` shipped [`Outcome::Locate`] in quasi 0.60 and //! the export destination took it first. These are its second and third //! consumers here. //! - **Reachability and the scan's numbers** — host facts the app has already //! resolved before a frame runs, so they arrive on [`Storage`](super::Storage) //! the way the themes arrive on `S`. No handler reads a disk. //! - **The path itself** — a string the host spells and collapses. The //! description never parses one, and `~` is applied where `dirs::home_dir` //! can be called. //! //! Nothing was left. The refusal was true when it was written and had been //! false for three days, which is the argument for counting: a "no" recorded //! against a vocabulary keeps its wording after the vocabulary moves. //! //! # The row's click is not described, and its confirmation is //! //! The shipped row was clickable, and clicking it asked //! `ConfirmAction::SwitchLibrary` first — but only when //! [`interrupting`](super::Storage::interrupting) said there was work to lose. //! A [`Row::activate`] carries an [`Action`] and not an [`Act`], so it has //! nowhere to put a confirmation. Rather than describe a click that could skip //! the asking, the row activates only when switching is free, and the `Open` //! act beside it is what carries [`Act::confirm`] when it is not. Same rule as //! the shipped panel, said on the control, and it is the fourth //! `ConfirmAction` variant this port has replaced with a builder method. //! //! # What the form's three fields cost, and what has been paid off //! //! `bebfd112` filed the Add Library form as the site where //! `makeover_immediate::group` could not be used: one describable field out of //! three, because the folder picker was a button and a path and the storage //! style was a hand-rolled radio pair. Both halves have closed since — //! `FieldKind::Radio` in makeover-layout 0.8.1, which the shipped form had //! already taken, and [`Outcome::Locate`] for the picker — so all three are //! described here and the form is one question after another with nothing //! hand-rolled between them. //! //! The name is remembered per keystroke rather than held by the renderer, which //! looks like a cost and is a requirement: the picker leaves and comes back, and //! a name living only in the form would not survive the round trip. That is the //! same `changes`-writes-through shape every other control on this screen has. //! //! # THE FINDING: an act has no standing help //! //! Six controls in this section carried an `on_hover_text`, and three of them //! said something the label does not: what Cleanup orphans does to other synced //! devices, that Backfill yields to an analysis you start, that Verify reports //! into the status line. [`Field::hint`](quasi_router::Field::hint) is standing //! help about an *answer* and [`Act::confirm`] is a question asked before a //! *write*; there is nothing that means "standing help about this control". //! //! So the three are prose beside the act instead, which is what the storage //! style's own hint argued for in the shipped form — help that is shown rather //! than hunted for. The other three restated their labels and are gone. Whether //! `Act` should carry a hint is a real question and this is its first consumer; //! a hover is not the shape to ask for, since half the hosts have no pointer. //! //! [`Act`]: quasi_router::Act //! [`Act::confirm`]: quasi_router::Act::confirm //! [`Action`]: quasi_router::Action //! [`Outcome::Locate`]: quasi_router::Outcome::Locate //! [`Row::activate`]: quasi_router::Row::activate use quasi_declare::declare; use quasi_router::layout::Tone; use quasi_router::{Action, Choice, Locating, Outcome, Request, Response, RouteError, Router, Tag}; use super::Panels; /// The name a picked folder comes back under. const FOLDER: &str = "folder"; /// Where a library change leaves you: browsing the library it changed to. const BROWSER: &str = "/"; /// The name a typed library name is submitted under. const NAME: &str = "name"; /// What switching costs when there is work in flight. const INTERRUPT: &str = "An import or bulk action is running and will be cancelled. Open this library anyway?"; /// What removing a library does and does not do. const FORGET: &str = "Remove this library from the list? Its files and database are left where they are."; /// The one fact that applies to the storage-style question rather than to /// either answer. const STYLE_HINT: &str = "Cannot be changed after the library is created."; /// Register the Storage section's routes. pub fn routes(router: Router>) -> Router> { router .post("/settings/storage/open/{at}", open) .post("/settings/storage/rename/{at}", rename_row) .post("/settings/storage/rename/{at}/save", rename) .post("/settings/storage/cancel-rename", cancel_rename) .post("/settings/storage/forget/{at}", forget) .post("/settings/storage/locate/{at}", locate) .post("/settings/storage/relocate/{at}", relocate) .post("/settings/storage/scan", scan) .post("/settings/storage/orphans", orphans) .post("/settings/storage/backfill", backfill) .post("/settings/storage/verify", verify) .post("/settings/storage/folder", folder) .post("/settings/storage/draft/{key}", draft) .post("/settings/storage/create", create) .post("/settings/storage/add", add_existing) .post("/settings/storage/discard", discard) } /// `POST /settings/storage/open/{at}` /// /// Leaves for the browser rather than answering with Settings again, which is /// what opening a library is for. Said as a navigation, since Settings is not a /// window. fn open(state: &Panels<'_>, request: Request) -> Result { state.storage.open(row(&request)?); Ok(Response::from(Outcome::Goto(Action::get(BROWSER)))) } /// `POST /settings/storage/rename/{at}` /// /// Shows the form; it does not rename anything. The form appears on the next /// frame, because the intent that opens it lands after this answer was built — /// which is what `Runtime::reload` and `Described::stale` are for. fn rename_row(state: &Panels<'_>, request: Request) -> Result { state.storage.rename_row(Some(row(&request)?)); settled(state) } /// `POST /settings/storage/rename/{at}/save` /// /// An empty name closes the form and changes nothing, which is the rule /// [`naming`](super::naming) settled for the four modals and the same one the /// shipped inline form followed. fn rename(state: &Panels<'_>, request: Request) -> Result { let at = row(&request)?; let typed = request.payload.get(NAME).unwrap_or_default().trim(); if !typed.is_empty() { state.storage.rename(at, typed); } state.storage.rename_row(None); settled(state) } /// `POST /settings/storage/cancel-rename` fn cancel_rename(state: &Panels<'_>, _request: Request) -> Result { state.storage.rename_row(None); settled(state) } /// `POST /settings/storage/forget/{at}` fn forget(state: &Panels<'_>, request: Request) -> Result { state.storage.forget(row(&request)?); settled(state) } /// `POST /settings/storage/locate/{at}` /// /// Second consumer of [`Outcome::Locate`](quasi_router::Outcome::Locate) on this /// host. The answer comes back to [`relocate`] with the folder under [`FOLDER`], /// and a reader who backs out of the picker has answered nothing. fn locate(_state: &Panels<'_>, request: Request) -> Result { let at = row(&request)?; Ok(Response::locate(Locating::folder( "Locate library directory", Action::post(format!("/settings/storage/relocate/{at}")), FOLDER, ))) } /// `POST /settings/storage/relocate/{at}` fn relocate(state: &Panels<'_>, request: Request) -> Result { let at = row(&request)?; let folder = request.payload.get(FOLDER).unwrap_or_default(); if !folder.is_empty() { state.storage.relocate(at, folder); } settled(state) } /// `POST /settings/storage/scan` fn scan(state: &Panels<'_>, _request: Request) -> Result { state.storage.rescan(); settled(state) } /// `POST /settings/storage/orphans` fn orphans(state: &Panels<'_>, _request: Request) -> Result { state.storage.cleanup_orphans(); settled(state) } /// `POST /settings/storage/backfill` fn backfill(state: &Panels<'_>, _request: Request) -> Result { state.storage.backfill(); settled(state) } /// `POST /settings/storage/verify` fn verify(state: &Panels<'_>, _request: Request) -> Result { state.storage.verify(); settled(state) } /// `POST /settings/storage/folder` /// /// Third consumer of [`Outcome::Locate`](quasi_router::Outcome::Locate), and the /// form shape of it rather than the act shape: the answer goes to the route that /// remembers the folder, and the form redraws with the path beside the control. fn folder(_state: &Panels<'_>, _request: Request) -> Result { Ok(Response::locate(Locating::folder( "Choose folder", Action::post("/settings/storage/draft/folder"), FOLDER, ))) } /// `POST /settings/storage/draft/{key}` /// /// One route for the form's three questions, the same shape /// [`settings`](super::settings) uses for every control it owns. An undeclared /// key is a `NotFound` rather than a silent no-op: the address is reachable by /// typing. fn draft(state: &Panels<'_>, request: Request) -> Result { let key = request.captures.require("key")?; match key { NAME => state .storage .draft_name(request.payload.get(NAME).unwrap_or_default()), FOLDER => { let folder = request.payload.get(FOLDER).unwrap_or_default(); if !folder.is_empty() { state.storage.draft_folder(folder); } } "style" => { let style = request.payload.get("style").unwrap_or_default(); state.storage.draft_style(style == "reference"); } _ => return Err(RouteError::not_found("no such field")), } settled(state) } /// `POST /settings/storage/create` fn create(state: &Panels<'_>, _request: Request) -> Result { if !state.storage.draft().ready() { return Err(RouteError::not_found("the form is not finished")); } state.storage.create(); Ok(Response::from(Outcome::Goto(Action::get(BROWSER)))) } /// `POST /settings/storage/add` fn add_existing(state: &Panels<'_>, _request: Request) -> Result { if !state.storage.draft().ready() { return Err(RouteError::not_found("the form is not finished")); } state.storage.add_existing(); Ok(Response::from(Outcome::Goto(Action::get(BROWSER)))) } /// `POST /settings/storage/discard` fn discard(state: &Panels<'_>, _request: Request) -> Result { state.storage.discard(); settled(state) } /// The settings window again, which is what every act here answers with. fn settled(state: &Panels<'_>) -> Result { super::settings::showing(state) } /// The row an address names. fn row(request: &Request) -> Result { request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("that is not a row")) } /// What the Storage section draws, read off the app. pub(super) struct Storage { /// The libraries, one row each. libraries: Vec, /// The inline rename, against whichever row is being renamed. renaming: Option, /// Scan, and the three maintenance passes over the open library. maintenance: Maintenance, /// Whether the open library references samples in place. loose: bool, /// The Add Library form. draft: Draft, } /// One library, as the row it is. struct Library { /// What it is called. name: String, /// Its directory, with the home prefix collapsed the way the reader saw it. shown: String, /// What the row is badged with, where it is badged with anything. badge: Option<&'static str>, /// The one extra line the row carries. /// /// At most one, and structurally rather than by luck: an offline row says /// its last-known path and the open row says what the scan counted, and no /// row is both. The offline row's path was a hover in the shipped panel; a /// hover is a host's, so it is said outright and a terminal or a screen /// reader gets to keep it. meta: Option, /// Where it sits, which is what every one of its addresses is built from. at: usize, /// Switching to it, where it can be switched to. open: Option, /// Whether it can be pointed at a new directory. locatable: bool, /// Whether it can be forgotten. forgettable: bool, } /// Switching to a library that is not the open one. struct Open { /// Whether the switch would interrupt a running job, which is what makes it /// ask first. /// /// The press is offered on the row itself only where it cannot skip an /// asking. See the module header. interrupting: bool, } /// The inline rename, against whichever row is being renamed. struct Renaming { /// Where that row sits. at: usize, /// What it is called now. current: String, } /// Scan, and the three maintenance passes over the open library. /// /// Each of the three says it is running by being disabled rather than by /// swapping its label, which is the shipped busy state minus the spinner: a /// spinner is a renderer's way of drawing "working", and every host has one or /// has something better. struct Maintenance { /// What the scan control reads. scan: &'static str, /// Whether a scan is running. scanning: bool, /// What the last scan counted, where one has run. counted: Option, /// What the backfill control reads. backfill: &'static str, /// Whether the feature backfill is running. backfilling: bool, /// Whether the maintenance worker is busy, which is what the integrity check /// shares its flag with. busy: bool, } /// What the last scan counted, and how fresh the numbers are. struct Counted { /// The three totals. totals: String, /// How long ago it ran. age: String, /// Whether that is long enough to say so. tone: Tone, } /// The Add Library form: a name, a folder, and how samples are stored. struct Draft { /// What the new library is to be called. name: String, /// The complaint, where a folder is chosen and the name is empty. /// /// The error the shipped form left unexplained: Create New disabled itself /// and said nothing about why. error: Option<&'static str>, /// The folder the host picked, where one was picked. folder: Option, /// Which storage style is chosen. style: &'static str, /// Whether that style is the one that breaks if the originals move. loose: bool, /// Whether the form has enough to act on. ready: bool, /// Whether it has anything in it to discard. started: bool, } /// What the section draws, read off the app. pub(super) fn read(state: &Panels<'_>) -> Storage { let all = state.storage.libraries(); let scan = state.storage.scan(); let interrupting = state.storage.interrupting(); let draft = state.storage.draft(); let scanning = state.storage.scanning(); let backfilling = state.storage.backfilling(); Storage { libraries: all .iter() .enumerate() .map(|(at, entry)| Library { name: entry.name.clone(), shown: entry.shown.clone(), badge: if entry.active { Some("active") } else if entry.reachable { None } else { Some("offline") }, // Only the open library has been counted: the scan reads the // database that is open, and the shipped rows were path-only // for the rest. meta: match (entry.active, entry.reachable, scan) { (true, _, Some(scan)) => Some(format!( "{} samples \u{b7} {}", scan.samples, bytes(scan.total_bytes), )), (false, false, _) => Some(format!("Last known path: {}", entry.path)), _ => None, }, at, open: (!entry.active && entry.reachable).then_some(Open { interrupting }), locatable: !entry.active && !entry.reachable, forgettable: !entry.active, }) .collect(), renaming: state.storage.renaming().map(|at| Renaming { at, current: all .get(at) .map(|entry| entry.name.clone()) .unwrap_or_default(), }), maintenance: Maintenance { scan: if scanning { "Scanning..." } else { "Scan" }, scanning, counted: scan.map(|scan| { let (age, stale) = scan_age(scan.age_secs); Counted { totals: format!( "{} samples, {} total, {} database", scan.samples, bytes(scan.total_bytes), bytes(scan.db_bytes), ), age, tone: if stale { Tone::Warning } else { Tone::Neutral }, } }), backfill: if backfilling { "Backfilling audio features..." } else { "Backfill audio features" }, backfilling, busy: state.storage.busy(), }, loose: state.storage.loose_files(), draft: Draft { error: (draft.folder.is_some() && draft.name.trim().is_empty()) .then_some("A library needs a name."), style: if draft.reference_in_place { "reference" } else { "copy" }, loose: draft.reference_in_place, ready: draft.ready(), started: draft.started(), name: draft.name, folder: draft.folder, }, } } declare! { /// The whole section, spliced into the settings body. /// /// Nodes rather than a `Slot` handed in and handed back, which is the shape /// a declaration is refused and the shape the settings screen cannot splice /// now that it is one. The three sections beside this one made the same /// move. pub(super) shape section(storage: &Storage) -> Vec; section "Storage"; text "Each library is an independent sample collection with its own \ database and files. A library can contain multiple vaults (top-level \ browse buckets)."; include libraries(storage); for renaming in storage.renaming.iter() { include rename_form(renaming); } extend maintenance(&storage.maintenance); toned "This library uses loose-files mode. Samples are referenced in place, \ not duplicated." Tone::Warning when storage.loose; extend add_library(&storage.draft); } declare! { /// The libraries, one row each. shape libraries(storage: &Storage) -> Node; list { for library in storage.libraries.iter() { row &library.name { secondary &library.shown; for &badge in library.badge.iter() { token Tag::badge(badge); } for line in library.meta.iter() { meta line; } for open in library.open.iter() { // See the module header: the click is only offered where it // cannot skip an asking. activate to post "/settings/storage/open/{library.at}" unless open.interrupting; act "Open" to post "/settings/storage/open/{library.at}" { confirm INTERRUPT when open.interrupting; } } act "Rename" to post "/settings/storage/rename/{library.at}"; act "Locate" to post "/settings/storage/locate/{library.at}" when library.locatable; act "Remove" to post "/settings/storage/forget/{library.at}" when library.forgettable { tone Danger; confirm FORGET; } } } } } declare! { /// The inline rename, against whichever row is being renamed. shape rename_form(renaming: &Renaming) -> Node; form post "/settings/storage/rename/{renaming.at}/save" { submit "Save"; field Text NAME "New name" { value &renaming.current; } } } declare! { /// Scan, and the three maintenance passes over the open library. See /// [`Maintenance`]. shape maintenance(maintenance: &Maintenance) -> Vec; act maintenance.scan to post "/settings/storage/scan" { disabled when maintenance.scanning; } for counted in maintenance.counted.iter() { text &counted.totals; toned &counted.age counted.tone; } text "Free disk by deleting samples no longer referenced anywhere in the \ library. Local-only: other synced devices keep their own copies."; act "Cleanup orphans" to post "/settings/storage/orphans"; text "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."; act maintenance.backfill to post "/settings/storage/backfill" { disabled when maintenance.backfilling; } text "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."; act "Verify library integrity" to post "/settings/storage/verify" { disabled when maintenance.busy; } } declare! { /// The Add Library form: a name, a folder, and how samples are stored. shape add_library(draft: &Draft) -> Vec; section "Add Library"; field Text "create_name" "Name" { value &draft.name; required; for &why in draft.error.iter() { error why; } writes Action::post("/settings/storage/draft/name"); } act "Choose folder..." to post "/settings/storage/folder"; for folder in draft.folder.iter() { text folder; } // A radio and not a select, which is what `FieldKind::Radio` was added for // in makeover-layout 0.8.1 and this is the call site named in its argument: // the alternatives to an irreversible choice have to be readable without // opening anything. field Radio "style" "Storage style" { option Choice::new("copy", "Copy samples into library (recommended)"); option Choice::new("reference", "Reference samples in place (loose-files mode)"); value draft.style; hint STYLE_HINT; writes Action::post("/settings/storage/draft/style"); } toned "Moving or deleting originals will break references. This cannot be \ undone." Tone::Warning when draft.loose; text "Create New makes an empty library in that folder. Add Existing adopts \ one that is already there."; act "Create New" to post "/settings/storage/create" { disabled unless draft.ready; } act "Add Existing" to post "/settings/storage/add" { disabled unless draft.ready; } act "Cancel" to post "/settings/storage/discard" { disabled unless draft.started; } } /// A byte count, as the app spells one everywhere else. fn bytes(count: u64) -> String { crate::ui::widgets::format_bytes(count) } /// How fresh the scan's numbers are, and whether they are stale enough to say so. /// /// Lifted from the deleted `ui/settings_panel.rs` with its threshold intact: a /// day old is when cached numbers stop being worth trusting. fn scan_age(age_secs: i64) -> (String, bool) { let stale = age_secs >= 86_400; // The shipped copy ran ", re-scan to refresh." straight onto "ago." and // read as "ago., re-scan". Restored as a sentence of its own. let suffix = if stale { " Re-scan to refresh." } else { "" }; let text = if age_secs < 120 { format!("Last scanned just now.{suffix}") } else if age_secs < 3_600 { format!("Last scanned {} minutes ago.{suffix}", age_secs / 60) } else if age_secs < 86_400 { let hours = age_secs / 3_600; format!( "Last scanned {hours} hour{} ago.{suffix}", if hours == 1 { "" } else { "s" } ) } else { let days = age_secs / 86_400; format!( "Last scanned {days} day{} ago.{suffix}", if days == 1 { "" } else { "s" } ) }; (text, stale) } #[cfg(test)] mod tests { use super::scan_age; #[test] fn scan_age_reads_fresh_and_stale() { assert_eq!(scan_age(30), ("Last scanned just now.".to_owned(), false)); assert_eq!( scan_age(300), ("Last scanned 5 minutes ago.".to_owned(), false) ); assert_eq!( scan_age(3_600), ("Last scanned 1 hour ago.".to_owned(), false) ); assert_eq!( scan_age(7_200), ("Last scanned 2 hours ago.".to_owned(), false) ); let (text, stale) = scan_age(172_800); assert!(stale); assert_eq!(text, "Last scanned 2 days ago. Re-scan to refresh."); assert_eq!( scan_age(90_000).0, "Last scanned 1 day ago. Re-scan to refresh." ); } }