Skip to main content

max / audiofiles

Describe Settings > Trash, which the flip took out of the app The second of the five sections the flip left behind. `/settings` describes it and `draw_settings` serves it, so a deleted sample can be restored or purged from the app again. The refusal was the weakest of the five and counting took one pass. `quasi/settings.rs` had it as "filesystem sizes and a destructive sweep over them": the rows are `samples WHERE deleted_at IS NOT NULL`, the size is a column on that table, and the sweep is `Store::sweep_expired_tombstones` at startup. This screen has never swept anything. What was on it is a list of app data and two acts per row, which is the most ordinary shape this port has had. Addressed by hash rather than by position, unlike Storage: a content address is one URL segment and is the table's own primary key, so the routes carry the real handle and a stale one fails where it should. Three things the port replaced rather than ported. The two-step purge was a `ConfirmAction` in a trench coat: a field, a button swap and four branches of a hand-rolled state machine, all of it `Act::confirm().tone()`. `SettingsUiState::trash_confirm_purge` is deleted, not left unread. The hash was a hover, and a hover is a host's. Eight characters as a badge now, which is the abbreviation `backend/sample_info.rs` already uses; the acts still carry the whole address, and a test holds that line. The retention window was the literal "30 days". `sample_tombstone_retain_days` is a synced `user_config` key, so an install that had shortened its window was told the wrong number by the one screen whose subject is that number. Read now, in the sentence and in the confirmation. `ensure_trash_loaded` runs in `draw_settings` before the router is asked, which is the housekeeping `draw_sync` already does above its own window: a route cannot fetch, so the host fetches first. Trash no longer blocks `cefe4159`; three sections of the five still do.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 21:23 UTC
Signed with PGP, not checked
Commit: a451e383bb6ddf41ecba26c159e62e9733011abe
Parent: f88a8d1
6 files changed, +628 insertions, -28 deletions
@@ -27,6 +27,7 @@
27 27 //! | [`Bar`] | [`toolbar`] | an [`Intent`], applied after the frame |
28 28 //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame |
29 29 //! | [`Storage`] | [`storage`] | an [`Intent`], applied after the frame |
30 + //! | [`Trash`] | [`trash`] | an [`Intent`], applied after the frame |
30 31 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
31 32 //!
32 33 //! The themes are the settled rule from goingson's settings port applied first
@@ -140,6 +141,7 @@
140 141 pub mod storage;
141 142 pub mod sync;
142 143 pub mod toolbar;
144 + pub mod trash;
143 145
144 146 use audiofiles_core::config_key::ConfigKey;
145 147 use quasi_router::Router;
@@ -919,6 +921,10 @@
919 921 PurgeLooseFiles,
920 922 /// Ask the host for a folder to look for the missing files in.
921 923 LocateLooseFiles,
924 + /// Bring a tombstoned sample back.
925 + RestoreSample(String),
926 + /// Remove a tombstoned sample now, skipping the retention window.
927 + PurgeSample(String),
922 928 /// Open this library.
923 929 SwitchLibrary(std::path::PathBuf),
924 930 /// Show the rename form against this library, or against none.
@@ -4616,6 +4622,132 @@
4616 4622 now.saturating_sub(at).max(0)
4617 4623 }
4618 4624
4625 + /// The tombstoned samples, as much of them as the Trash section needs.
4626 + ///
4627 + /// The thirteenth narrow trait, and the smallest: a list out of the app's own
4628 + /// table, and two acts per row.
4629 + ///
4630 + /// **The stated refusal was the weakest of the five the flip left behind.**
4631 + /// [`settings`](super::settings)'s header called this "filesystem sizes and a
4632 + /// destructive sweep over them", and it is neither. The rows come out of
4633 + /// `samples WHERE deleted_at IS NOT NULL` through `ensure_trash_loaded`; the
4634 + /// size is a column on that table; and the sweep that enforces the retention
4635 + /// window runs at startup from `Store::sweep_expired_tombstones`, not from this
4636 + /// screen. Nothing here touches a disk and nothing here sweeps.
4637 + ///
4638 + /// # Addressed by hash, which is the app's own handle
4639 + ///
4640 + /// [`Storage`] had to name a row by position because a library's handle is a
4641 + /// path and a path is not one URL segment. A tombstone's handle is its content
4642 + /// address: 64 hex characters, one segment, and the primary key of the table
4643 + /// the row came from. So these routes carry the real thing, and a stale address
4644 + /// fails the way it should — the sample is not in the trash, and the store says
4645 + /// so.
4646 + pub trait Trash {
4647 + /// Every tombstoned sample, most recently deleted first.
4648 + fn deleted(&self) -> Vec<Deleted>;
4649 +
4650 + /// How long a deleted sample is kept before the sweep takes it.
4651 + ///
4652 + /// Read rather than assumed. `sample_tombstone_retain_days` is a synced
4653 + /// `user_config` key and the shipped section wrote "30 days" into its
4654 + /// sentence regardless, so an install that had shortened the window was
4655 + /// told the wrong number by the one screen whose job is to say it.
4656 + fn retain_days(&self) -> i64;
4657 +
4658 + /// Bring this sample back, on every device.
4659 + fn restore(&self, hash: &str);
4660 +
4661 + /// Remove it now, skipping the window.
4662 + fn purge(&self, hash: &str);
4663 + }
4664 +
4665 + /// One tombstoned sample, as the description names it.
4666 + #[derive(Debug, Clone, PartialEq, Eq)]
4667 + pub struct Deleted {
4668 + /// Its content address, which is both the handle and what tells two
4669 + /// same-named samples apart.
4670 + pub hash: String,
4671 + /// What the file was called, without its extension.
4672 + pub name: String,
4673 + /// The extension, empty when it had none.
4674 + pub extension: String,
4675 + /// What it takes up, never negative.
4676 + pub size_bytes: u64,
4677 + /// How long ago it was deleted, in seconds.
4678 + ///
4679 + /// The same split [`Scan`] makes: the clock is a syscall and the wording is
4680 + /// the description's.
4681 + pub age_secs: i64,
4682 + }
4683 +
4684 + impl Deleted {
4685 + /// The name a reader sees, extension included when there is one.
4686 + #[must_use]
4687 + pub fn filename(&self) -> String {
4688 + if self.extension.is_empty() {
4689 + self.name.clone()
4690 + } else {
4691 + format!("{}.{}", self.name, self.extension)
4692 + }
4693 + }
4694 + }
4695 +
4696 + /// The app's trash, as the narrow thing the section borrows.
4697 + pub struct FromTrash<'a> {
4698 + /// What the app has loaded.
4699 + pub state: &'a crate::state::BrowserState,
4700 + /// What the described screen asked for, applied after the frame.
4701 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4702 + }
4703 +
4704 + impl Trash for FromTrash<'_> {
4705 + fn deleted(&self) -> Vec<Deleted> {
4706 + self.state
4707 + .settings
4708 + .trash
4709 + .iter()
4710 + .map(|entry| Deleted {
4711 + hash: entry.hash.clone(),
4712 + name: entry.original_name.clone(),
4713 + extension: entry.file_extension.clone(),
4714 + size_bytes: u64::try_from(entry.file_size).unwrap_or(0),
4715 + age_secs: age_of(entry.deleted_at),
4716 + })
4717 + .collect()
4718 + }
4719 +
4720 + fn retain_days(&self) -> i64 {
4721 + self.state
4722 + .backend
4723 + .get_config(ConfigKey::SampleTombstoneRetainDays)
4724 + .ok()
4725 + .flatten()
4726 + .and_then(|days| days.parse().ok())
4727 + .filter(|days| *days >= 0)
4728 + .unwrap_or(RETAIN_DAYS)
4729 + }
4730 +
4731 + fn restore(&self, hash: &str) {
4732 + self.intents
4733 + .borrow_mut()
4734 + .push(Intent::RestoreSample(hash.to_owned()));
4735 + }
4736 +
4737 + fn purge(&self, hash: &str) {
4738 + self.intents
4739 + .borrow_mut()
4740 + .push(Intent::PurgeSample(hash.to_owned()));
4741 + }
4742 + }
4743 +
4744 + /// How long a tombstone is kept when nothing says otherwise.
4745 + ///
4746 + /// The same default `store::tombstone_retain_days` applies, restated here rather
4747 + /// than reached for: that function takes a `&Database`, which is on the far side
4748 + /// of `Backend`, and this side reads the key it reads.
4749 + const RETAIN_DAYS: i64 = 30;
4750 +
4619 4751 /// The library-wide tag queue, as much of it as a described screen needs.
4620 4752 ///
4621 4753 /// One struct where the flow is an enum, and [`Forging`]'s reason again: the
@@ -5635,6 +5767,8 @@
5635 5767 pub filters: &'a dyn Filters,
5636 5768 /// The libraries on this machine, for the settings window's Storage section.
5637 5769 pub storage: &'a dyn Storage,
5770 + /// The tombstoned samples, for the settings window's Trash section.
5771 + pub trash: &'a dyn Trash,
5638 5772 /// The themes on offer, resolved by the host at startup.
5639 5773 pub themes: &'a [ThemeChoice],
5640 5774 }
@@ -5648,9 +5782,9 @@
5648 5782 filters::routes(queue::routes(forge::routes(edit::routes(
5649 5783 integrity::routes(importing::routes(naming::routes(toolbar::routes(
5650 5784 library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
5651 - export::routes(files::routes(sync::routes(storage::routes(
5785 + export::routes(files::routes(sync::routes(trash::routes(storage::routes(
5652 5786 settings::routes(Router::new()),
5653 - )))),
5787 + ))))),
5654 5788 ))))),
5655 5789 )))),
5656 5790 ))))
@@ -35,7 +35,8 @@
35 35 use super::{
36 36 FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge,
37 37 FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromStorage,
38 - FromSyncManager, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
38 + FromSyncManager, FromTrash, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice,
39 + Unconfigured,
39 40 };
40 41 use crate::state::BrowserState;
41 42 use crate::ui::theme;
@@ -101,6 +102,12 @@
101 102
102 103 /// Draw the described settings window, and act on whatever was pressed.
103 104 pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) {
105 + // The Trash section reads a list the app loads on demand, and loading it is
106 + // `&mut`. Same housekeeping `draw_sync` does above its own window: a route
107 + // cannot fetch, so the host fetches before it asks. Cheap after the first
108 + // time — `ensure_trash_loaded` is a flag check once the list is in hand.
109 + state.ensure_trash_loaded();
110 +
104 111 let intents = RefCell::new(Vec::new());
105 112 let mut runtime = state.described.settings.take();
106 113 let stale = state.described.stale;
@@ -1527,6 +1534,9 @@
1527 1534 }
1528 1535 }
1529 1536 Intent::DiscardLibraryDraft => discard_draft(state),
1537 + // Both refresh the trash list themselves, so nothing here has to.
1538 + Intent::RestoreSample(hash) => state.undelete_sample(&hash),
1539 + Intent::PurgeSample(hash) => state.purge_sample(&hash),
1530 1540 }
1531 1541 }
1532 1542 }
@@ -2332,6 +2342,7 @@
2332 2342 let queue = FromQueue { state, intents };
2333 2343 let filters = FromFilters { state, intents };
2334 2344 let storage = FromStorage { state, intents };
2345 + let trash = FromTrash { state, intents };
2335 2346 let panels = Panels {
2336 2347 config: &config,
2337 2348 sync,
@@ -2350,6 +2361,7 @@
2350 2361 queue: &queue,
2351 2362 filters: &filters,
2352 2363 storage: &storage,
2364 + trash: &trash,
2353 2365 themes,
2354 2366 };
2355 2367 super::router()
@@ -41,14 +41,15 @@
41 41 //! | Storage | yes | as of the section's own port; see [`storage`](super::storage) |
42 42 //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog |
43 43 //! | License | **no** | a key exchanged with a server |
44 - //! | Trash | **no** | filesystem sizes and a destructive sweep over them |
44 + //! | Trash | yes | as of the section's own port; see [`trash`](super::trash) |
45 45 //! | Classifier | **no** | its own model state, and bespoke |
46 46 //!
47 47 //! Storage was the honest kind of "no" until it was counted. It is described
48 48 //! now, in [`storage`](super::storage), and what its header records is that two
49 49 //! of the three doors it needed had opened before anyone re-read the refusal.
50 - //! Trash keeps the same wording and the same weakness: its rows are tombstones
51 - //! out of the app's own table.
50 + //! Trash was the same shape of "no" and a weaker one: its rows are tombstones
51 + //! out of the app's own table, its sizes are a column, and its sweep runs at
52 + //! startup. It is described now, in [`trash`](super::trash).
52 53 //!
53 54 //! **Advanced was the interesting one, and half of it is answered.** This port
54 55 //! filed "a control that asks the host where to put something and then acts has
@@ -201,6 +202,7 @@
201 202 .with(Node::Field(Box::new(row_height(state)?)));
202 203
203 204 body = super::storage::section(body, state);
205 + body = super::trash::section(body, state);
204 206
205 207 // Advanced, half of it. See the header: Export Current is describable as of
206 208 // quasi 0.50.0 and Import Theme is not, so the section is what the
@@ -13,14 +13,14 @@
13 13
14 14 use super::{
15 15 Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown,
16 - Config, Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Draft, Editing, Export,
17 - Failure, Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format, Group,
18 - Halted, Holding, Importing, Integrity, Keys, Knob, Library, LibraryEntry, Measure, Measures,
19 - Migrating, Naming, Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing,
20 - ProfileChoice, Queue, Queued, Reviewed, Sample, Saying, Scan, Scope, Searching, Setting,
21 - Settings, Shared, Shell, Source, Spread, Stage, State, Status, Storage, Strategy, Subject,
22 - Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Vault, VaultChoice,
23 - Walked, Where, router,
16 + Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed, DeviceChoice, Draft, Editing,
17 + Export, Failure, Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format,
18 + Group, Halted, Holding, Importing, Integrity, Keys, Knob, Library, LibraryEntry, Measure,
19 + Measures, Migrating, Naming, Narrowing, Order, Panel, Panels, Phase, Playing, Preflight,
20 + Pricing, ProfileChoice, Queue, Queued, Reviewed, Sample, Saying, Scan, Scope, Searching,
21 + Setting, Settings, Shared, Shell, Source, Spread, Stage, State, Status, Storage, Strategy,
22 + Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Trash, Vault,
23 + VaultChoice, Walked, Where, router,
24 24 };
25 25
26 26 /// A config store in memory.
@@ -266,6 +266,7 @@
266 266 queue: &Unqueued,
267 267 filters: &Unfiltered,
268 268 storage: &OneLibrary,
269 + trash: &Emptied,
269 270 themes: &themes,
270 271 };
271 272 router().handle(&state, request)
@@ -432,6 +433,7 @@
432 433 queue: &Unqueued,
433 434 filters: &Unfiltered,
434 435 storage: &OneLibrary,
436 + trash: &Emptied,
435 437 themes: &themes,
436 438 };
437 439 router().handle(&state, request)
@@ -494,6 +496,7 @@
494 496 queue: &Unqueued,
495 497 filters: &Unfiltered,
496 498 storage: &OneLibrary,
499 + trash: &Emptied,
497 500 themes: &themes,
498 501 };
499 502 router().handle(&state, request)
@@ -659,6 +662,7 @@
659 662 queue: &Unqueued,
660 663 filters: &Unfiltered,
661 664 storage: &OneLibrary,
665 + trash: &Emptied,
662 666 themes: &themes,
663 667 };
664 668 let response = router()
@@ -692,23 +696,29 @@
692 696 // write at all -- it hands back a file. What this asserts is that no control
693 697 // grew an address of its own, which is the drift it exists to catch.
694 698 //
695 - // The Storage section is counted apart rather than folded in, because the
696 - // rule does not reach it and pretending it did would make this number
697 - // meaningless. Its controls are acts on a registry -- open a library, forget
698 - // one, count what is on disk -- and none of them is a key with a value. Only
699 - // its three-question form writes the way this screen does, and it does so
700 - // through one route.
701 - let counted = |prefix: &str, apart: bool| {
699 + // The sections that came back after the flip are counted apart rather than
700 + // folded in, because the rule does not reach them and pretending it did
701 + // would make this number meaningless. Their controls are acts on app data --
702 + // open a library, restore a deleted sample, count what is on disk -- and
703 + // none is a key with a value. Only Storage's three-question form writes the
704 + // way this screen does, and it does so through one route.
705 + const SECTIONS: [&str; 2] = ["/settings/storage", "/settings/trash"];
706 + let counted = |prefix: &str| {
702 707 table
703 708 .iter()
704 709 .filter(|(_, path)| {
705 - path.starts_with(prefix) && (apart || !path.starts_with("/settings/storage"))
710 + path.starts_with(prefix)
711 + && (prefix != "/settings"
712 + || !SECTIONS.iter().any(|section| path.starts_with(section)))
706 713 })
707 714 .count()
708 715 };
709 - assert_eq!(counted("/settings", false), 4, "{table:?}");
710 - assert_eq!(counted("/settings/storage", true), 16, "{table:?}");
716 + assert_eq!(counted("/settings"), 4, "{table:?}");
717 + assert_eq!(counted("/settings/storage"), 16, "{table:?}");
711 718 assert!(table.contains(&(Method::Post, "/settings/storage/draft/{key}".to_owned())));
719 + // Two acts per row and nothing else. The shipped section's arm-then-confirm
720 + // pair is `Act::confirm`, so there is no third address for the arming.
721 + assert_eq!(counted("/settings/trash"), 2, "{table:?}");
712 722 }
713 723
714 724 /// A router call against the settings screen, over a given config store.
@@ -734,6 +744,7 @@
734 744 queue: &Unqueued,
735 745 filters: &Unfiltered,
736 746 storage: &OneLibrary,
747 + trash: &Emptied,
737 748 themes: &themes,
738 749 };
739 750 router().handle(&state, request)
@@ -806,6 +817,7 @@
806 817 queue: &Unqueued,
807 818 filters: &Unfiltered,
808 819 storage: &OneLibrary,
820 + trash: &Emptied,
809 821 themes: &themes,
810 822 };
811 823
@@ -858,6 +870,7 @@
858 870 queue: &Unqueued,
859 871 filters: &Unfiltered,
860 872 storage: &OneLibrary,
873 + trash: &Emptied,
861 874 themes: &themes,
862 875 };
863 876 let refused = router().handle(
@@ -896,6 +909,7 @@
896 909 queue: &Unqueued,
897 910 filters: &Unfiltered,
898 911 storage: &OneLibrary,
912 + trash: &Emptied,
899 913 themes: &themes,
900 914 };
901 915
@@ -957,6 +971,7 @@
957 971 queue: &Unqueued,
958 972 filters: &Unfiltered,
959 973 storage: &OneLibrary,
974 + trash: &Emptied,
960 975 themes: &themes,
961 976 };
962 977 let response = router()
@@ -1014,6 +1029,7 @@
1014 1029 queue: &Unqueued,
1015 1030 filters: &Unfiltered,
1016 1031 storage: &OneLibrary,
1032 + trash: &Emptied,
1017 1033 themes: &themes,
1018 1034 };
1019 1035 let response = router()
@@ -1184,6 +1200,7 @@
1184 1200 queue: &Unqueued,
1185 1201 filters: &Unfiltered,
1186 1202 storage: &OneLibrary,
1203 + trash: &Emptied,
1187 1204 themes: &themes,
1188 1205 };
1189 1206 router().handle(&state, request)
@@ -2810,6 +2827,7 @@
2810 2827 queue: &Unqueued,
2811 2828 filters: &Unfiltered,
2812 2829 storage: &OneLibrary,
2830 + trash: &Emptied,
2813 2831 themes: &themes,
2814 2832 };
2815 2833 router().handle(&state, request)
@@ -3365,6 +3383,7 @@
3365 3383 queue: &Unqueued,
3366 3384 filters: &Unfiltered,
3367 3385 storage: &OneLibrary,
3386 + trash: &Emptied,
3368 3387 themes: &themes,
3369 3388 };
3370 3389 router().handle(&state, request)
@@ -3755,6 +3774,7 @@
3755 3774 queue: &Unqueued,
3756 3775 filters: &Unfiltered,
3757 3776 storage: &OneLibrary,
3777 + trash: &Emptied,
3758 3778 themes: &themes,
3759 3779 };
3760 3780 router().handle(&state, request)
@@ -4132,6 +4152,7 @@
4132 4152 queue: &Unqueued,
4133 4153 filters: &Unfiltered,
4134 4154 storage: &OneLibrary,
4155 + trash: &Emptied,
4135 4156 themes: &themes,
4136 4157 };
4137 4158 router().handle(&state, request)
@@ -4554,6 +4575,7 @@
4554 4575 queue: &Unqueued,
4555 4576 filters: &Unfiltered,
4556 4577 storage: &OneLibrary,
4578 + trash: &Emptied,
4557 4579 themes: &themes,
4558 4580 };
4559 4581 router().handle(&state, request)
@@ -5042,6 +5064,7 @@
5042 5064 queue: &Unqueued,
5043 5065 filters: &Unfiltered,
5044 5066 storage: &OneLibrary,
5067 + trash: &Emptied,
5045 5068 themes: &themes,
5046 5069 };
5047 5070 router().handle(&state, request)
@@ -5777,6 +5800,25 @@
5777 5800 fn discard(&self) {}
5778 5801 }
5779 5802
5803 + /// Nothing has been deleted, and the window is the default.
5804 + ///
5805 + /// The quiet fixture, matching [`OneLibrary`] and [`Sound`]. The section's own
5806 + /// tests use [`FakeTrash`], which holds rows and records.
5807 + struct Emptied;
5808 +
5809 + impl Trash for Emptied {
5810 + fn deleted(&self) -> Vec<Deleted> {
5811 + Vec::new()
5812 + }
5813 +
5814 + fn retain_days(&self) -> i64 {
5815 + 30
5816 + }
5817 +
5818 + fn restore(&self, _hash: &str) {}
5819 + fn purge(&self, _hash: &str) {}
5820 + }
5821 +
5780 5822 /// A namer in memory, recording what was asked of it and refusing on demand.
5781 5823 #[derive(Default)]
5782 5824 struct FakeNaming {
@@ -5899,6 +5941,7 @@
5899 5941 queue: &Unqueued,
5900 5942 filters: &Unfiltered,
5901 5943 storage: &OneLibrary,
5944 + trash: &Emptied,
5902 5945 themes: &themes,
5903 5946 };
5904 5947 router().handle(&state, request)
@@ -6062,6 +6105,7 @@
6062 6105 queue: &Unqueued,
6063 6106 filters: &Unfiltered,
6064 6107 storage: &OneLibrary,
6108 + trash: &Emptied,
6065 6109 themes: &themes,
6066 6110 };
6067 6111 router().handle(&state, request)
@@ -6369,6 +6413,7 @@
6369 6413 queue: &Unqueued,
6370 6414 filters: &Unfiltered,
6371 6415 storage: &OneLibrary,
6416 + trash: &Emptied,
6372 6417 themes: &themes,
6373 6418 };
6374 6419 router().handle(&state, request)
@@ -6453,6 +6498,7 @@
6453 6498 queue: &Unqueued,
6454 6499 filters: &Unfiltered,
6455 6500 storage: &OneLibrary,
6501 + trash: &Emptied,
6456 6502 themes: &themes,
6457 6503 };
6458 6504 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -6635,6 +6681,7 @@
6635 6681 queue: &Unqueued,
6636 6682 filters: &Unfiltered,
6637 6683 storage: &OneLibrary,
6684 + trash: &Emptied,
6638 6685 themes: &themes,
6639 6686 };
6640 6687 router().handle(&state, request)
@@ -8213,6 +8260,7 @@
8213 8260 queue: &Unqueued,
8214 8261 filters: &Unfiltered,
8215 8262 storage: &OneLibrary,
8263 + trash: &Emptied,
8216 8264 themes: &themes,
8217 8265 };
8218 8266 router().handle(&state, request)
@@ -8794,6 +8842,7 @@
8794 8842 queue: &Unqueued,
8795 8843 filters,
8796 8844 storage: &OneLibrary,
8845 + trash: &Emptied,
8797 8846 themes: &themes,
8798 8847 };
8799 8848 router().handle(&state, request)
@@ -8933,6 +8982,7 @@
8933 8982 queue,
8934 8983 filters: &Unfiltered,
8935 8984 storage: &OneLibrary,
8985 + trash: &Emptied,
8936 8986 themes: &themes,
8937 8987 };
8938 8988 router().handle(&state, request)
@@ -9625,6 +9675,7 @@
9625 9675 queue: &Unqueued,
9626 9676 filters: &Unfiltered,
9627 9677 storage,
9678 + trash: &Emptied,
9628 9679 themes: &themes,
9629 9680 };
9630 9681 router().handle(&state, request)
@@ -9965,3 +10016,252 @@
9965 10016 storing(&storage, answered).expect("answered");
9966 10017 assert_eq!(storage.asked(), ["draft_folder /mnt/fast/Kit"]);
9967 10018 }
10019 +
10020 + /// A trash in memory, recording what was asked of it.
10021 + #[derive(Default)]
10022 + struct FakeTrash {
10023 + deleted: Vec<Deleted>,
10024 + retain_days: Option<i64>,
10025 + asked: RefCell<Vec<String>>,
10026 + }
10027 +
10028 + impl FakeTrash {
10029 + /// Two deleted samples, one with an extension and one without, at the
10030 + /// default window.
10031 + fn two() -> Self {
10032 + Self {
10033 + deleted: vec![
10034 + Deleted {
10035 + hash: "abcdef0123456789".repeat(4),
10036 + name: "kick".to_owned(),
10037 + extension: "wav".to_owned(),
10038 + size_bytes: 2048,
10039 + age_secs: 3_600,
10040 + },
10041 + Deleted {
10042 + hash: "fedcba9876543210".repeat(4),
10043 + name: "noname".to_owned(),
10044 + extension: String::new(),
10045 + size_bytes: 0,
10046 + age_secs: 172_800,
10047 + },
10048 + ],
10049 + ..Self::default()
10050 + }
10051 + }
10052 +
10053 + /// What was asked of it, in order.
10054 + fn asked(&self) -> Vec<String> {
10055 + self.asked.borrow().clone()
10056 + }
10057 + }
10058 +
10059 + impl Trash for FakeTrash {
10060 + fn deleted(&self) -> Vec<Deleted> {
10061 + self.deleted.clone()
10062 + }
10063 +
10064 + fn retain_days(&self) -> i64 {
10065 + self.retain_days.unwrap_or(30)
10066 + }
10067 +
10068 + fn restore(&self, hash: &str) {
10069 + self.asked.borrow_mut().push(format!("restore {hash}"));
10070 + }
10071 +
10072 + fn purge(&self, hash: &str) {
10073 + self.asked.borrow_mut().push(format!("purge {hash}"));
10074 + }
10075 + }
10076 +
10077 + /// A router call against this trash.
10078 + fn trashing(trash: &FakeTrash, request: Request) -> Result<Response, quasi_router::RouteError> {
10079 + let store = Store::default();
10080 + let sync = Offline;
10081 + let files = FakeFiles::default();
10082 + let themes = themes();
10083 + let state = Panels {
10084 + detail: &Unfocused,
10085 + bulk: &Unchosen,
10086 + shell: &Quiet,
10087 + library: &Empty,
10088 + bar: &Still,
10089 + config: &store,
10090 + sync: &sync,
10091 + files: &files,
10092 + export: &Idle,
10093 + naming: &Unnamed,
10094 + importing: &NoImport,
10095 + integrity: &Sound,
10096 + editor: &Unedited,
10097 + forge: &Unforged,
10098 + queue: &Unqueued,
10099 + filters: &Unfiltered,
10100 + storage: &OneLibrary,
10101 + trash,
10102 + themes: &themes,
10103 + };
10104 + router().handle(&state, request)
10105 + }
10106 +
10107 + /// The settings screen this trash produces.
10108 + fn trashed(trash: &FakeTrash) -> Screen {
10109 + screen_of(&trashing(trash, Request::get("/settings")).expect("answered")).clone()
10110 + }
10111 +
10112 + /// The rows the Trash section describes.
10113 + ///
10114 + /// The Storage section is above it and has a list of its own, so this drops
10115 + /// that one rather than taking the first list it finds.
10116 + fn trash_rows(trash: &FakeTrash) -> Vec<quasi_router::Row> {
10117 + let screen = trashed(trash);
10118 + deep_rows(&screen)
10119 + .into_iter()
10120 + .filter(|row| {
10121 + row_acts(row)
10122 + .iter()
10123 + .any(|act| act.action.route().is_some_and(|at| at.contains("/trash/")))
10124 + })
10125 + .collect()
10126 + }
10127 +
10128 + #[test]
10129 + fn an_empty_trash_says_so_rather_than_showing_an_empty_list() {
10130 + let empty = FakeTrash::default();
10131 + assert!(trash_rows(&empty).is_empty());
10132 + assert!(
10133 + said_deep(&trashed(&empty)).contains("Trash is empty."),
10134 + "{}",
10135 + said_deep(&trashed(&empty))
10136 + );
10137 + }
10138 +
10139 + #[test]
10140 + fn a_deleted_sample_carries_its_name_size_and_age() {
10141 + let rows = trash_rows(&FakeTrash::two());
10142 + assert_eq!(rows.len(), 2);
10143 + assert_eq!(
10144 + said_in(&rows[0], quasi_router::layout::RowPart::Primary),
10145 + "kick.wav"
10146 + );
10147 + assert_eq!(
10148 + said_in(&rows[0], quasi_router::layout::RowPart::Meta),
10149 + "2.0 KB \u{b7} deleted 1 hour ago"
10150 + );
10151 + // No extension is not an empty extension: the shipped row appended a dot
10152 + // only when there was something to put after it.
10153 + assert_eq!(
10154 + said_in(&rows[1], quasi_router::layout::RowPart::Primary),
10155 + "noname"
10156 + );
10157 + }
10158 +
10159 + #[test]
10160 + fn the_hash_is_shown_rather_than_hovered_over() {
10161 + // The row's only way to tell two same-named deleted samples apart, and in a
10162 + // content-addressed manager that is not a rare case. A hover is a host's.
10163 + let trash = FakeTrash::two();
10164 + let rows = trash_rows(&trash);
10165 + let shown: Vec<String> = rows[0]
10166 + .role(quasi_router::layout::RowPart::Tokens)
10167 + .filter_map(|node| match node {
10168 + Node::Token(tag) => Some(tag.label.clone()),
10169 + _ => None,
10170 + })
10171 + .collect();
10172 + // Eight characters, which is what this crate abbreviates a hash to
10173 + // everywhere else. Long enough to disambiguate, short enough to sit in a
10174 + // row beside a name.
10175 + assert_eq!(shown, [trash.deleted[0].hash[..8].to_owned()]);
10176 + }
10177 +
10178 + #[test]
10179 + fn the_whole_address_is_what_the_acts_carry() {
10180 + // Shortening what is shown must not shorten what is called, which is the
10181 + // one way the badge could have gone wrong.
10182 + let trash = FakeTrash::two();
10183 + let rows = trash_rows(&trash);
10184 + let routes: Vec<String> = row_acts(&rows[0])
10185 + .iter()
10186 + .filter_map(|act| act.action.route().map(ToOwned::to_owned))
10187 + .collect();
10188 + let hash = &trash.deleted[0].hash;
10189 + assert_eq!(
10190 + routes,
10191 + [
10192 + format!("/settings/trash/{hash}/restore"),
10193 + format!("/settings/trash/{hash}/purge"),
10194 + ]
10195 + );
10196 + }
10197 +
10198 + #[test]
10199 + fn permanent_delete_asks_on_the_control_rather_than_arming_a_second_one() {
10200 + // The shipped row swapped its buttons for a Cancel/Delete-forever pair held
10201 + // in `trash_confirm_purge`. `Act::confirm` is that, and the field is gone.
10202 + let rows = trash_rows(&FakeTrash::two());
10203 + let acts = row_acts(&rows[0]);
10204 + let purge = acts
10205 + .iter()
10206 + .find(|act| act.label == "Delete permanently")
10207 + .expect("the row offers a permanent delete");
10208 + assert_eq!(purge.tone, quasi_router::layout::Tone::Danger);
10209 + let asked = purge.confirm.as_deref().expect("it asks first");
10210 + assert!(asked.contains("kick.wav"), "{asked}");
10211 + assert!(asked.contains("30-day window"), "{asked}");
10212 + assert!(
10213 + !acts.iter().any(|act| act.label == "Delete forever"),
10214 + "the armed state survived the port"
10215 + );
10216 +
10217 + let restore = acts
10218 + .iter()
10219 + .find(|act| act.label == "Restore")
10220 + .expect("the row offers a restore");
10221 + assert!(
10222 + restore.confirm.is_none(),
10223 + "restoring is reversible and should not ask"
10224 + );
10225 + }
10226 +
10227 + #[test]
10228 + fn the_retention_window_is_read_rather_than_written_down() {
10229 + // The shipped sentence said "30 days" as a literal, and
10230 + // `sample_tombstone_retain_days` is a synced key. An install that shortened
10231 + // its window was told the wrong number by the one screen about that number.
10232 + let mut trash = FakeTrash::two();
10233 + assert!(said_deep(&trashed(&trash)).contains("kept here for 30 days"));
10234 +
10235 + trash.retain_days = Some(7);
10236 + let said = said_deep(&trashed(&trash));
10237 + assert!(said.contains("kept here for 7 days"), "{said}");
10238 + let purge = row_acts(&trash_rows(&trash)[0])
10239 + .into_iter()
10240 + .find(|act| act.label == "Delete permanently")
10241 + .expect("the row offers a permanent delete");
10242 + assert!(
10243 + purge
10244 + .confirm
10245 + .as_deref()
10246 + .is_some_and(|ask| ask.contains("7-day window")),
10247 + "the confirmation kept a window the screen no longer claims"
Lines truncated
@@ -328,10 +328,6 @@
328 328 pub trash: Vec<audiofiles_core::store::TombstonedSample>,
329 329 /// True once `trash` has been loaded at least once this session.
330 330 pub trash_loaded: bool,
331 - /// Hash of the sample whose permanent-delete is awaiting confirmation, if
332 - /// any. Permanent delete is irreversible, so the first click arms this and
333 - /// the row swaps to a confirm/cancel pair.
334 - pub trash_confirm_purge: Option<String>,
335 331 }
336 332
337 333 /// GUI state for the tag-classifier sections in Settings (Layer A rules builder).
@@ -1,0 +1,176 @@
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_router::layout::Tone;
49 + use quasi_router::{Act, Action, Node, Request, Response, RouteError, Router, Row, Slot, 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 + Ok(super::settings::screen(state)?.into())
89 + }
90 +
91 + /// The whole section, added to the settings body.
92 + pub(super) fn section(body: Slot, state: &Panels<'_>) -> Slot {
93 + let days = state.trash.retain_days();
94 + let body = body
95 + .with(Node::section("Trash"))
96 + .with(Node::text(format!(
97 + "Deleted samples are kept here for {days} {}, then removed permanently. Restoring brings a sample back on all your devices.",
98 + if days == 1 { "day" } else { "days" },
99 + )));
100 +
101 + let deleted = state.trash.deleted();
102 + if deleted.is_empty() {
103 + return body.with(Node::empty("Trash is empty."));
104 + }
105 +
106 + let rows = deleted
107 + .iter()
108 + .map(|entry| {
109 + let mut row = Row::new(entry.filename()).meta(format!(
110 + "{} \u{b7} {}",
111 + crate::ui::widgets::format_bytes(entry.size_bytes),
112 + deleted_age(entry.age_secs),
113 + ));
114 +
115 + if entry.hash.len() >= SHOWN {
116 + row = row.token(Tag::badge(&entry.hash[..SHOWN]));
117 + }
118 +
119 + row.act(Act::new(
120 + "Restore",
121 + Action::post(format!("/settings/trash/{}/restore", entry.hash)),
122 + ))
123 + .act(
124 + Act::new(
125 + "Delete permanently",
126 + Action::post(format!("/settings/trash/{}/purge", entry.hash)),
127 + )
128 + .tone(Tone::Danger)
129 + .confirm(format!(
130 + "Remove \"{}\" now, skipping the {days}-day window? This cannot be undone.",
131 + entry.filename(),
132 + )),
133 + )
134 + })
135 + .collect();
136 +
137 + body.with(Node::List { rows, more: None })
138 + }
139 +
140 + /// How long ago a sample was deleted.
141 + ///
142 + /// Lifted from the deleted `ui/settings_panel.rs`, clamp included: `deleted_at`
143 + /// is a stored timestamp and a clock that has gone backwards would otherwise
144 + /// read as a sample deleted in the future.
145 + fn deleted_age(age_secs: i64) -> String {
146 + let age = age_secs.max(0);
147 + if age < 120 {
148 + "deleted just now".to_owned()
149 + } else if age < 3_600 {
150 + format!("deleted {} minutes ago", age / 60)
151 + } else if age < 86_400 {
152 + let hours = age / 3_600;
153 + format!(
154 + "deleted {hours} hour{} ago",
155 + if hours == 1 { "" } else { "s" }
156 + )
157 + } else {
158 + let days = age / 86_400;
159 + format!("deleted {days} day{} ago", if days == 1 { "" } else { "s" })
160 + }
161 + }
162 +
163 + #[cfg(test)]
164 + mod tests {
165 + use super::deleted_age;
166 +
167 + #[test]
168 + fn deleted_age_pluralizes_and_clamps_negatives() {
169 + assert_eq!(deleted_age(-5), "deleted just now");
170 + assert_eq!(deleted_age(600), "deleted 10 minutes ago");
171 + assert_eq!(deleted_age(3_600), "deleted 1 hour ago");
172 + assert_eq!(deleted_age(7_200), "deleted 2 hours ago");
173 + assert_eq!(deleted_age(86_400), "deleted 1 day ago");
174 + assert_eq!(deleted_age(172_800), "deleted 2 days ago");
175 + }
176 + }