Skip to main content

max / audiofiles

7.7 KB · 206 lines History Blame Raw
1 //! The settings window's Trash section, described: what has been deleted, how
2 //! long it is kept, and the two things that can be done to a row.
3 //!
4 //! The second of the five sections the settings flip left behind, and the one
5 //! whose stated refusal was weakest. [`settings`](super::settings) had ruled it
6 //! out as "filesystem sizes and a destructive sweep over them". Counted:
7 //!
8 //! - The rows are `samples WHERE deleted_at IS NOT NULL`, the app's own table.
9 //! - The size is a column on it, not a `stat`.
10 //! - The sweep is `Store::sweep_expired_tombstones`, which runs at startup. This
11 //! screen has never swept anything.
12 //!
13 //! What was actually on screen is a list of app data and two acts per row, which
14 //! is the most ordinary shape this port has. The refusal was about the words
15 //! "delete" and "size" rather than about what the section does.
16 //!
17 //! # The two-step purge was a `ConfirmAction` in a trench coat
18 //!
19 //! The shipped row armed itself: pressing "Delete permanently" swapped the pair
20 //! of buttons for "Cancel" and "Delete forever", held in
21 //! `SettingsUiState::trash_confirm_purge`, one hash at a time. That is
22 //! [`Act::confirm`] with a tone, and the substitution is the one
23 //! [`quasi`](super)'s header already argues for. **A field, a swap, and four
24 //! branches of a hand-rolled state machine, replaced by two builder calls** —
25 //! and `trash_confirm_purge` is deleted rather than left unread, because a field
26 //! nothing writes is the next reader's puzzle.
27 //!
28 //! # The hash was a hover, and it is a badge now
29 //!
30 //! `ui.label(&name).on_hover_text(&entry.hash)` is the row's only way to tell
31 //! two deleted samples with the same name apart, which in a content-addressed
32 //! manager is not a rare case. A hover is a host's, so the fact is said instead:
33 //! the first eight characters as a badge, which is the abbreviation
34 //! `backend/sample_info.rs` already uses in this crate. The whole address is
35 //! still what the acts carry, so nothing is lost by shortening what is shown.
36 //!
37 //! # The retention window is read rather than written down
38 //!
39 //! The shipped sentence said "30 days" as a literal.
40 //! `sample_tombstone_retain_days` is a synced `user_config` key with a default
41 //! of 30, so an install that had shortened its window was told the wrong number
42 //! by the one screen whose subject is that number. [`Trash::retain_days`] is the
43 //! fix, and the confirmation on the purge carries it too.
44 //!
45 //! [`Act::confirm`]: quasi_router::Act::confirm
46 //! [`Trash::retain_days`]: super::Trash::retain_days
47
48 use quasi_declare::declare;
49 use quasi_router::{Request, Response, RouteError, Router, Tag};
50
51 use super::Panels;
52
53 /// How much of a content address a row shows.
54 ///
55 /// Eight, which is what `backend/sample_info.rs` abbreviates a hash to when it
56 /// names one in a log line. Enough to tell two same-named samples apart and
57 /// short enough to sit in a row.
58 const SHOWN: usize = 8;
59
60 /// Register the Trash section's routes.
61 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
62 router
63 .post("/settings/trash/{hash}/restore", restore)
64 .post("/settings/trash/{hash}/purge", purge)
65 }
66
67 /// `POST /settings/trash/{hash}/restore`
68 fn restore(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
69 let hash = request.captures.require("hash")?;
70 state.trash.restore(hash);
71 settled(state)
72 }
73
74 /// `POST /settings/trash/{hash}/purge`
75 ///
76 /// The asking has already happened: the act that reaches here carries
77 /// [`Act::confirm`](quasi_router::Act::confirm), so this is the answer rather
78 /// than the question. That is why there is no arm-then-confirm pair of routes to
79 /// match the shipped pair of buttons.
80 fn purge(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
81 let hash = request.captures.require("hash")?;
82 state.trash.purge(hash);
83 settled(state)
84 }
85
86 /// The settings window again, which is what both acts answer with.
87 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
88 super::settings::showing(state)
89 }
90
91 /// What the section draws, read off the app once.
92 pub(super) struct Bin {
93 /// How long a deleted sample is kept, in days.
94 days: i64,
95 /// "day" or "days", to agree with `days` in the sentence above the list.
96 unit: &'static str,
97 /// What is in there now, most recently deleted first.
98 gone: Vec<Gone>,
99 }
100
101 /// One tombstoned sample, in the words the row uses.
102 struct Gone {
103 /// Its content address, which is what both acts carry.
104 hash: String,
105 /// The name a reader sees.
106 filename: String,
107 /// Size and age, on the line under the name.
108 meta: String,
109 /// The head of the hash, when the hash has a head worth showing.
110 short: Option<String>,
111 }
112
113 /// What the section draws, read off the app.
114 pub(super) fn read(state: &Panels<'_>) -> Bin {
115 let days = state.trash.retain_days();
116 Bin {
117 days,
118 unit: if days == 1 { "day" } else { "days" },
119 gone: state
120 .trash
121 .deleted()
122 .iter()
123 .map(|entry| Gone {
124 hash: entry.hash.clone(),
125 filename: entry.filename(),
126 meta: format!(
127 "{} \u{b7} {}",
128 crate::ui::widgets::format_bytes(entry.size_bytes),
129 deleted_age(entry.age_secs),
130 ),
131 short: (entry.hash.len() >= SHOWN).then(|| entry.hash[..SHOWN].to_owned()),
132 })
133 .collect(),
134 }
135 }
136
137 declare! {
138 /// The whole section, spliced into the settings body.
139 pub(super) shape section(trash: &Bin) -> Vec<Node>;
140
141 section "Trash";
142 text "Deleted samples are kept here for {trash.days} {trash.unit}, then removed \
143 permanently. Restoring brings a sample back on all your devices.";
144
145 given trash.gone.is_empty() {
146 true -> empty "Trash is empty.";
147 otherwise -> list {
148 for entry in trash.gone.iter() {
149 row &entry.filename {
150 meta &entry.meta;
151
152 for short in entry.short.iter() {
153 token Tag::badge(short);
154 }
155
156 act "Restore" to post "/settings/trash/{entry.hash}/restore";
157
158 act "Delete permanently" to post "/settings/trash/{entry.hash}/purge" {
159 tone Danger;
160 confirm "Remove \"{entry.filename}\" now, skipping the \
161 {trash.days}-day window? This cannot be undone.";
162 }
163 }
164 }
165 }
166 }
167 }
168
169 /// How long ago a sample was deleted.
170 ///
171 /// Lifted from the deleted `ui/settings_panel.rs`, clamp included: `deleted_at`
172 /// is a stored timestamp and a clock that has gone backwards would otherwise
173 /// read as a sample deleted in the future.
174 fn deleted_age(age_secs: i64) -> String {
175 let age = age_secs.max(0);
176 if age < 120 {
177 "deleted just now".to_owned()
178 } else if age < 3_600 {
179 format!("deleted {} minutes ago", age / 60)
180 } else if age < 86_400 {
181 let hours = age / 3_600;
182 format!(
183 "deleted {hours} hour{} ago",
184 if hours == 1 { "" } else { "s" }
185 )
186 } else {
187 let days = age / 86_400;
188 format!("deleted {days} day{} ago", if days == 1 { "" } else { "s" })
189 }
190 }
191
192 #[cfg(test)]
193 mod tests {
194 use super::deleted_age;
195
196 #[test]
197 fn deleted_age_pluralizes_and_clamps_negatives() {
198 assert_eq!(deleted_age(-5), "deleted just now");
199 assert_eq!(deleted_age(600), "deleted 10 minutes ago");
200 assert_eq!(deleted_age(3_600), "deleted 1 hour ago");
201 assert_eq!(deleted_age(7_200), "deleted 2 hours ago");
202 assert_eq!(deleted_age(86_400), "deleted 1 day ago");
203 assert_eq!(deleted_age(172_800), "deleted 2 days ago");
204 }
205 }
206