//! The settings window's Trash section, described: what has been deleted, how //! long it is kept, and the two things that can be done to a row. //! //! The second of the five sections the settings flip left behind, and the one //! whose stated refusal was weakest. [`settings`](super::settings) had ruled it //! out as "filesystem sizes and a destructive sweep over them". Counted: //! //! - The rows are `samples WHERE deleted_at IS NOT NULL`, the app's own table. //! - The size is a column on it, not a `stat`. //! - The sweep is `Store::sweep_expired_tombstones`, which runs at startup. This //! screen has never swept anything. //! //! What was actually on screen is a list of app data and two acts per row, which //! is the most ordinary shape this port has. The refusal was about the words //! "delete" and "size" rather than about what the section does. //! //! # The two-step purge was a `ConfirmAction` in a trench coat //! //! The shipped row armed itself: pressing "Delete permanently" swapped the pair //! of buttons for "Cancel" and "Delete forever", held in //! `SettingsUiState::trash_confirm_purge`, one hash at a time. That is //! [`Act::confirm`] with a tone, and the substitution is the one //! [`quasi`](super)'s header already argues for. **A field, a swap, and four //! branches of a hand-rolled state machine, replaced by two builder calls** — //! and `trash_confirm_purge` is deleted rather than left unread, because a field //! nothing writes is the next reader's puzzle. //! //! # The hash was a hover, and it is a badge now //! //! `ui.label(&name).on_hover_text(&entry.hash)` is the row's only way to tell //! two deleted samples with the same name apart, which in a content-addressed //! manager is not a rare case. A hover is a host's, so the fact is said instead: //! the first eight characters as a badge, which is the abbreviation //! `backend/sample_info.rs` already uses in this crate. The whole address is //! still what the acts carry, so nothing is lost by shortening what is shown. //! //! # The retention window is read rather than written down //! //! The shipped sentence said "30 days" as a literal. //! `sample_tombstone_retain_days` is a synced `user_config` key with a default //! of 30, so an install that had shortened its window was told the wrong number //! by the one screen whose subject is that number. [`Trash::retain_days`] is the //! fix, and the confirmation on the purge carries it too. //! //! [`Act::confirm`]: quasi_router::Act::confirm //! [`Trash::retain_days`]: super::Trash::retain_days use quasi_declare::declare; use quasi_router::{Request, Response, RouteError, Router, Tag}; use super::Panels; /// How much of a content address a row shows. /// /// Eight, which is what `backend/sample_info.rs` abbreviates a hash to when it /// names one in a log line. Enough to tell two same-named samples apart and /// short enough to sit in a row. const SHOWN: usize = 8; /// Register the Trash section's routes. pub fn routes(router: Router>) -> Router> { router .post("/settings/trash/{hash}/restore", restore) .post("/settings/trash/{hash}/purge", purge) } /// `POST /settings/trash/{hash}/restore` fn restore(state: &Panels<'_>, request: Request) -> Result { let hash = request.captures.require("hash")?; state.trash.restore(hash); settled(state) } /// `POST /settings/trash/{hash}/purge` /// /// The asking has already happened: the act that reaches here carries /// [`Act::confirm`](quasi_router::Act::confirm), so this is the answer rather /// than the question. That is why there is no arm-then-confirm pair of routes to /// match the shipped pair of buttons. fn purge(state: &Panels<'_>, request: Request) -> Result { let hash = request.captures.require("hash")?; state.trash.purge(hash); settled(state) } /// The settings window again, which is what both acts answer with. fn settled(state: &Panels<'_>) -> Result { super::settings::showing(state) } /// What the section draws, read off the app once. pub(super) struct Bin { /// How long a deleted sample is kept, in days. days: i64, /// "day" or "days", to agree with `days` in the sentence above the list. unit: &'static str, /// What is in there now, most recently deleted first. gone: Vec, } /// One tombstoned sample, in the words the row uses. struct Gone { /// Its content address, which is what both acts carry. hash: String, /// The name a reader sees. filename: String, /// Size and age, on the line under the name. meta: String, /// The head of the hash, when the hash has a head worth showing. short: Option, } /// What the section draws, read off the app. pub(super) fn read(state: &Panels<'_>) -> Bin { let days = state.trash.retain_days(); Bin { days, unit: if days == 1 { "day" } else { "days" }, gone: state .trash .deleted() .iter() .map(|entry| Gone { hash: entry.hash.clone(), filename: entry.filename(), meta: format!( "{} \u{b7} {}", crate::ui::widgets::format_bytes(entry.size_bytes), deleted_age(entry.age_secs), ), short: (entry.hash.len() >= SHOWN).then(|| entry.hash[..SHOWN].to_owned()), }) .collect(), } } declare! { /// The whole section, spliced into the settings body. pub(super) shape section(trash: &Bin) -> Vec; section "Trash"; text "Deleted samples are kept here for {trash.days} {trash.unit}, then removed \ permanently. Restoring brings a sample back on all your devices."; given trash.gone.is_empty() { true -> empty "Trash is empty."; otherwise -> list { for entry in trash.gone.iter() { row &entry.filename { meta &entry.meta; for short in entry.short.iter() { token Tag::badge(short); } act "Restore" to post "/settings/trash/{entry.hash}/restore"; act "Delete permanently" to post "/settings/trash/{entry.hash}/purge" { tone Danger; confirm "Remove \"{entry.filename}\" now, skipping the \ {trash.days}-day window? This cannot be undone."; } } } } } } /// How long ago a sample was deleted. /// /// Lifted from the deleted `ui/settings_panel.rs`, clamp included: `deleted_at` /// is a stored timestamp and a clock that has gone backwards would otherwise /// read as a sample deleted in the future. fn deleted_age(age_secs: i64) -> String { let age = age_secs.max(0); if age < 120 { "deleted just now".to_owned() } else if age < 3_600 { format!("deleted {} minutes ago", age / 60) } else if age < 86_400 { let hours = age / 3_600; format!( "deleted {hours} hour{} ago", if hours == 1 { "" } else { "s" } ) } else { let days = age / 86_400; format!("deleted {days} day{} ago", if days == 1 { "" } else { "s" }) } } #[cfg(test)] mod tests { use super::deleted_age; #[test] fn deleted_age_pluralizes_and_clamps_negatives() { assert_eq!(deleted_age(-5), "deleted just now"); assert_eq!(deleted_age(600), "deleted 10 minutes ago"); assert_eq!(deleted_age(3_600), "deleted 1 hour ago"); assert_eq!(deleted_age(7_200), "deleted 2 hours ago"); assert_eq!(deleted_age(86_400), "deleted 1 day ago"); assert_eq!(deleted_age(172_800), "deleted 2 days ago"); } }