max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |