Skip to main content

max / audiofiles

Describe Settings > Storage, which the flip took out of the app The largest hole the settings flip left: `draw_storage_section` was 430 of the 1,182 lines that went with `ui/settings_panel.rs`, and the section has been missing from the app since. `/settings` describes it now and `draw_settings` serves it, so switching, renaming, removing, relocating and creating a library are reachable again. The refusal it was carrying was counted rather than restated, which is what the task asked for. `quasi/settings.rs` had ruled Storage out as "library paths, reachability, relocation: the filesystem", written before `Outcome::Locate` existed. Counted, it was three things and none of them survived: asking for a place is `Locating` since quasi 0.60, reachability and the scan's numbers are host facts already resolved before a frame runs, and the path is a string the host spells. No handler here reads a disk. New capability `Storage` plus `FromStorage`, sixteen routes under `/settings/storage`, and fifteen intents that write through the `VaultAction` queue the app layer already drains. Nothing was invented to receive them. Two things the port replaced rather than ported. `ConfirmAction::SwitchLibrary` is `Act::confirm` on the row, so the row-click is offered only where it cannot skip the asking; that is the fourth variant of that enum this port has deleted in favour of a builder method. And the Add Library form is three described fields where `bebfd112` filed one, since `FieldKind::Radio` and `Outcome::Locate` have both landed since it was written. THE FINDING: an act has no standing help. Six controls here carried an `on_hover_text` and three said something the label does not. `Field::hint` is about an answer and `Act::confirm` is a question before a write; there is nothing for standing help about a control. The three are prose beside the act now and the three that restated their labels are gone. Storage no longer blocks `cefe4159`; four 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 20:54 UTC
Signed with PGP, not checked
Commit: f88a8d18f6909f31743af515a9086ebdc4877402
Parent: 99ce7e1
6 files changed, +1364 insertions, -24 deletions
M Cargo.lock +4 -4
@@ -7554,10 +7554,6 @@
7554 7554 "winnow 1.0.4",
7555 7555 ]
7556 7556
7557 - [[patch.unused]]
7558 - name = "quasi-type"
7559 - version = "0.1.0"
7560 -
7561 7557 [[patch.unused]]
7562 7558 name = "kberg"
7563 7559 version = "0.1.0"
@@ -7597,3 +7593,7 @@
7597 7593 [[patch.unused]]
7598 7594 name = "quasi-webview"
7599 7595 version = "0.61.1"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-type"
7599 + version = "0.1.0"
@@ -26,6 +26,7 @@
26 26 //! | [`Library`] | [`library`] | an [`Intent`], applied after the frame |
27 27 //! | [`Bar`] | [`toolbar`] | an [`Intent`], applied after the frame |
28 28 //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame |
29 + //! | [`Storage`] | [`storage`] | an [`Intent`], applied after the frame |
29 30 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
30 31 //!
31 32 //! The themes are the settled rule from goingson's settings port applied first
@@ -136,6 +137,7 @@
136 137 pub mod queue;
137 138 pub mod settings;
138 139 pub mod shell;
140 + pub mod storage;
139 141 pub mod sync;
140 142 pub mod toolbar;
141 143
@@ -917,6 +919,46 @@
917 919 PurgeLooseFiles,
918 920 /// Ask the host for a folder to look for the missing files in.
919 921 LocateLooseFiles,
922 + /// Open this library.
923 + SwitchLibrary(std::path::PathBuf),
924 + /// Show the rename form against this library, or against none.
925 + RenamingLibrary(Option<(std::path::PathBuf, String)>),
926 + /// Give this library a new name in the registry.
927 + RenameLibrary {
928 + /// Which library.
929 + path: std::path::PathBuf,
930 + /// What it is now called.
931 + name: String,
932 + },
933 + /// Take this library out of the registry, leaving its files alone.
934 + ForgetLibrary(std::path::PathBuf),
935 + /// Point a library's registry entry at a directory that is there.
936 + RelocateLibrary {
937 + /// Where the registry says it is.
938 + old: std::path::PathBuf,
939 + /// Where it is.
940 + new: std::path::PathBuf,
941 + },
942 + /// Count what the open library has on disk.
943 + ScanStorage,
944 + /// Delete samples nothing refers to any more.
945 + CleanupOrphans,
946 + /// Compute the feature data tag suggestions want.
947 + BackfillFeatures,
948 + /// Re-hash every stored sample against its content address.
949 + VerifyIntegrity,
950 + /// Remember the Add Library form's name.
951 + DraftLibraryName(String),
952 + /// Remember the folder the host picked for it.
953 + DraftLibraryFolder(std::path::PathBuf),
954 + /// Remember whether it copies samples in or references them in place.
955 + DraftLibraryStyle(bool),
956 + /// Make a library out of the form.
957 + CreateLibrary,
958 + /// Adopt a library directory that already exists.
959 + AddExistingLibrary,
960 + /// Throw the form away.
961 + DiscardLibraryDraft,
920 962 /// Cut the edited sample down to this span.
921 963 EditTrim {
922 964 /// Where the kept part starts, as a fraction of the whole.
@@ -4189,6 +4231,391 @@
4189 4231 }
4190 4232 }
4191 4233
4234 + /// The libraries on this machine, as much of them as the Storage section needs.
4235 + ///
4236 + /// The twelfth narrow trait, and the one the flip was measured against: the
4237 + /// section it serves was 430 of `ui/settings_panel.rs`'s 1,182 lines, the
4238 + /// largest single thing the described settings window did not say.
4239 + ///
4240 + /// **The header's "no" was about the filesystem, and two of its three doors have
4241 + /// opened since.** `quasi/settings.rs` ruled Storage out as "library paths,
4242 + /// reachability, relocation: the filesystem", written before `Outcome::Locate`
4243 + /// existed. Counted against what the section actually needs:
4244 + ///
4245 + /// - *Relocation and Choose folder* are a route asking the host for a place,
4246 + /// which is [`Locating`](quasi_router::Locating) since quasi 0.60.
4247 + /// - *Reachability and the scan's numbers* are host facts the app has already
4248 + /// resolved by the time a frame runs, so they come in on this trait the way
4249 + /// [`ThemeChoice`] comes in on `S`. Nothing here reads a disk.
4250 + /// - *The path* is a string the host spells and collapses; the description
4251 + /// never parses one.
4252 + ///
4253 + /// What is left of the refusal is nothing, which is why counting was the
4254 + /// instruction rather than restating it.
4255 + ///
4256 + /// # A row is addressed by position
4257 + ///
4258 + /// [`LibraryEntry`] carries no id because the registry has none: the app's own
4259 + /// handle is the path, and a path is not one URL segment. So a route names a
4260 + /// row by its index into [`libraries`](Self::libraries) and this adapter
4261 + /// resolves it back to a path while it still holds the state. The same shape
4262 + /// `ReadReviewed` and `RemoveFromBasket` take, for the same reason.
4263 + pub trait Storage {
4264 + /// Every library in the registry, active one included.
4265 + fn libraries(&self) -> Vec<LibraryEntry>;
4266 +
4267 + /// What the last scan counted, and how long ago it ran.
4268 + fn scan(&self) -> Option<Scan>;
4269 +
4270 + /// Whether a scan is running.
4271 + fn scanning(&self) -> bool;
4272 +
4273 + /// Whether the feature backfill is running.
4274 + fn backfilling(&self) -> bool;
4275 +
4276 + /// Whether the maintenance worker is busy, which is what the integrity
4277 + /// check shares its flag with.
4278 + fn busy(&self) -> bool;
4279 +
4280 + /// Whether the open library references samples in place.
4281 + fn loose_files(&self) -> bool;
4282 +
4283 + /// Whether switching now would interrupt something.
4284 + ///
4285 + /// The shipped row asked for confirmation only in this case, and the
4286 + /// described row says [`Act::confirm`](quasi_router::Act::confirm) only in
4287 + /// this case. Same rule, said on the control instead of in a ten-variant
4288 + /// enum.
4289 + fn interrupting(&self) -> bool;
4290 +
4291 + /// Which row is being renamed, if one is.
4292 + fn renaming(&self) -> Option<usize>;
4293 +
4294 + /// The Add Library form as it stands.
4295 + fn draft(&self) -> Draft;
4296 +
4297 + /// Browse this library.
4298 + fn open(&self, at: usize);
4299 +
4300 + /// Show the rename form against this row, or against none.
4301 + fn rename_row(&self, at: Option<usize>);
4302 +
4303 + /// Give this library a new name in the registry.
4304 + fn rename(&self, at: usize, name: &str);
4305 +
4306 + /// Take this library out of the registry, leaving its files alone.
4307 + fn forget(&self, at: usize);
4308 +
4309 + /// Point this library's registry entry at a directory that is there.
4310 + fn relocate(&self, at: usize, folder: &str);
4311 +
4312 + /// Count what is on disk.
4313 + fn rescan(&self);
4314 +
4315 + /// Delete samples nothing refers to any more.
4316 + fn cleanup_orphans(&self);
4317 +
4318 + /// Compute the feature data tag suggestions want.
4319 + fn backfill(&self);
4320 +
4321 + /// Re-hash every stored sample against its content address.
4322 + fn verify(&self);
4323 +
4324 + /// Remember a piece of the Add Library form.
4325 + fn draft_name(&self, name: &str);
4326 +
4327 + /// Remember the folder the host picked.
4328 + fn draft_folder(&self, folder: &str);
4329 +
4330 + /// Remember whether samples are copied in or referenced in place.
4331 + fn draft_style(&self, reference_in_place: bool);
4332 +
4333 + /// Make a library out of the form.
4334 + fn create(&self);
4335 +
4336 + /// Adopt a library directory that already exists.
4337 + fn add_existing(&self);
4338 +
4339 + /// Throw the form away.
4340 + fn discard(&self);
4341 + }
4342 +
4343 + /// One library in the registry, as the description names it.
4344 + #[derive(Debug, Clone, PartialEq, Eq)]
4345 + pub struct LibraryEntry {
4346 + /// What it is called.
4347 + pub name: String,
4348 + /// Its directory, with the home prefix collapsed the way the reader saw it.
4349 + ///
4350 + /// The collapsing is the host's: `dirs::home_dir` is a syscall and the
4351 + /// abbreviation is this host's convention rather than a fact about the
4352 + /// library. A terminal renderer that spelled `~` differently would still be
4353 + /// handed this string, which is the one cost of putting it here and is the
4354 + /// same cost `Status::last_sync_at` pays.
4355 + pub shown: String,
4356 + /// The directory in full, for the offline row's hover.
4357 + pub path: String,
4358 + /// Whether this is the library that is open.
4359 + pub active: bool,
4360 + /// Whether its directory is where the registry says it is.
4361 + pub reachable: bool,
4362 + }
4363 +
4364 + /// What a scan counted.
4365 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4366 + pub struct Scan {
4367 + /// How many samples are in the library.
4368 + pub samples: u64,
4369 + /// What they take up.
4370 + pub total_bytes: u64,
4371 + /// What the database takes up.
4372 + pub db_bytes: u64,
4373 + /// How long ago the count was taken, in seconds.
4374 + ///
4375 + /// A number rather than the sentence the shipped panel wrote, so the words
4376 + /// stay in the description and only the clock is the host's. `SystemTime`
4377 + /// is a syscall no handler can make, which is why the arithmetic happens
4378 + /// here.
4379 + pub age_secs: i64,
4380 + }
4381 +
4382 + /// The Add Library form as it stands.
4383 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
4384 + pub struct Draft {
4385 + /// What the new library is to be called.
4386 + pub name: String,
4387 + /// The folder the host picked, if one was picked.
4388 + pub folder: Option<String>,
4389 + /// Whether samples are referenced in place rather than copied in.
4390 + pub reference_in_place: bool,
4391 + }
4392 +
4393 + impl Draft {
4394 + /// Whether the form has enough to act on.
4395 + #[must_use]
4396 + pub fn ready(&self) -> bool {
4397 + !self.name.trim().is_empty() && self.folder.is_some()
4398 + }
4399 +
4400 + /// Whether the form has anything in it to discard.
4401 + #[must_use]
4402 + pub fn started(&self) -> bool {
4403 + !self.name.trim().is_empty() || self.folder.is_some()
4404 + }
4405 + }
4406 +
4407 + /// The app's library registry, as the narrow thing the Storage section borrows.
4408 + pub struct FromStorage<'a> {
4409 + /// What the app has loaded.
4410 + pub state: &'a crate::state::BrowserState,
4411 + /// What the described screen asked for, applied after the frame.
4412 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4413 + }
4414 +
4415 + impl Storage for FromStorage<'_> {
4416 + fn libraries(&self) -> Vec<LibraryEntry> {
4417 + let active = &self.state.data_dir;
4418 + self.state
4419 + .settings
4420 + .list
4421 + .iter()
4422 + .map(|(name, path, reachable)| LibraryEntry {
4423 + name: name.clone(),
4424 + shown: collapse_home(path),
4425 + path: path.display().to_string(),
4426 + active: path == active,
4427 + reachable: *reachable,
4428 + })
4429 + .collect()
4430 + }
4431 +
4432 + fn scan(&self) -> Option<Scan> {
4433 + let stats = self.state.settings.storage_cache.as_ref()?;
4434 + let at = self.state.settings.storage_cache_at?;
4435 + Some(Scan {
4436 + samples: stats.sample_count,
4437 + total_bytes: stats.total_bytes,
4438 + db_bytes: stats.db_bytes,
4439 + age_secs: age_of(at),
4440 + })
4441 + }
4442 +
4443 + fn scanning(&self) -> bool {
4444 + matches!(
4445 + self.state.settings.pending_action,
4446 + Some(crate::state::VaultAction::ScanStorage)
4447 + )
4448 + }
4449 +
4450 + fn backfilling(&self) -> bool {
4451 + self.state.import_wf.backfill_in_progress
4452 + }
4453 +
4454 + fn busy(&self) -> bool {
4455 + self.state.loose_files.loose_files_busy
4456 + }
4457 +
4458 + fn loose_files(&self) -> bool {
4459 + self.state.settings.is_loose_files
4460 + }
4461 +
4462 + fn interrupting(&self) -> bool {
4463 + self.state.has_in_flight_work()
4464 + }
4465 +
4466 + fn renaming(&self) -> Option<usize> {
4467 + let (path, _) = self.state.settings.rename_target.as_ref()?;
4468 + self.state
4469 + .settings
4470 + .list
4471 + .iter()
4472 + .position(|(_, known, _)| known == path)
4473 + }
4474 +
4475 + fn draft(&self) -> Draft {
4476 + Draft {
4477 + name: self.state.settings.create_name.clone(),
4478 + folder: self
4479 + .state
4480 + .settings
4481 + .create_path
4482 + .as_ref()
4483 + .map(|path| path.display().to_string()),
4484 + reference_in_place: self.state.settings.create_loose_files,
4485 + }
4486 + }
4487 +
4488 + fn open(&self, at: usize) {
4489 + if let Some(path) = self.path(at) {
4490 + self.push(Intent::SwitchLibrary(path));
4491 + }
4492 + }
4493 +
4494 + fn rename_row(&self, at: Option<usize>) {
4495 + match at {
4496 + Some(at) => {
4497 + if let Some(path) = self.path(at) {
4498 + let name = self.state.settings.list[at].0.clone();
4499 + self.push(Intent::RenamingLibrary(Some((path, name))));
4500 + }
4501 + }
4502 + None => self.push(Intent::RenamingLibrary(None)),
4503 + }
4504 + }
4505 +
4506 + fn rename(&self, at: usize, name: &str) {
4507 + if let Some(path) = self.path(at) {
4508 + self.push(Intent::RenameLibrary {
4509 + path,
4510 + name: name.to_owned(),
4511 + });
4512 + }
4513 + }
4514 +
4515 + fn forget(&self, at: usize) {
4516 + if let Some(path) = self.path(at) {
4517 + self.push(Intent::ForgetLibrary(path));
4518 + }
4519 + }
4520 +
4521 + fn relocate(&self, at: usize, folder: &str) {
4522 + if let Some(path) = self.path(at) {
4523 + self.push(Intent::RelocateLibrary {
4524 + old: path,
4525 + new: std::path::PathBuf::from(folder),
4526 + });
4527 + }
4528 + }
4529 +
4530 + fn rescan(&self) {
4531 + self.push(Intent::ScanStorage);
4532 + }
4533 +
4534 + fn cleanup_orphans(&self) {
4535 + self.push(Intent::CleanupOrphans);
4536 + }
4537 +
4538 + fn backfill(&self) {
4539 + self.push(Intent::BackfillFeatures);
4540 + }
4541 +
4542 + fn verify(&self) {
4543 + self.push(Intent::VerifyIntegrity);
4544 + }
4545 +
4546 + fn draft_name(&self, name: &str) {
4547 + self.push(Intent::DraftLibraryName(name.to_owned()));
4548 + }
4549 +
4550 + fn draft_folder(&self, folder: &str) {
4551 + self.push(Intent::DraftLibraryFolder(std::path::PathBuf::from(folder)));
4552 + }
4553 +
4554 + fn draft_style(&self, reference_in_place: bool) {
4555 + self.push(Intent::DraftLibraryStyle(reference_in_place));
4556 + }
4557 +
4558 + fn create(&self) {
4559 + self.push(Intent::CreateLibrary);
4560 + }
4561 +
4562 + fn add_existing(&self) {
4563 + self.push(Intent::AddExistingLibrary);
4564 + }
4565 +
4566 + fn discard(&self) {
4567 + self.push(Intent::DiscardLibraryDraft);
4568 + }
4569 + }
4570 +
4571 + impl FromStorage<'_> {
4572 + /// Record what the described screen asked for.
4573 + fn push(&self, intent: Intent) {
4574 + self.intents.borrow_mut().push(intent);
4575 + }
4576 +
4577 + /// The path a row index names, while the state is still in hand.
4578 + ///
4579 + /// `None` for an index that is not a row, which is what a typed address
4580 + /// gets. Every write below is a no-op in that case rather than a refusal:
4581 + /// the route has already answered with the screen, and the row it named is
4582 + /// gone.
4583 + fn path(&self, at: usize) -> Option<std::path::PathBuf> {
4584 + self.state
4585 + .settings
4586 + .list
4587 + .get(at)
4588 + .map(|(_, path, _)| path.clone())
4589 + }
4590 + }
4591 +
4592 + /// A path with the home prefix collapsed to `~`.
4593 + ///
4594 + /// Lifted verbatim from the deleted `ui/settings_panel.rs`. It is here rather
4595 + /// than in the description because `dirs::home_dir` is a syscall.
4596 + fn collapse_home(path: &std::path::Path) -> String {
4597 + let display = path.display().to_string();
4598 + let Some(home) = dirs::home_dir() else {
4599 + return display;
4600 + };
4601 + let home = home.display().to_string();
4602 + match display.strip_prefix(&home) {
4603 + Some("") => "~".to_owned(),
4604 + Some(rest) => format!("~{rest}"),
4605 + None => display,
4606 + }
4607 + }
4608 +
4609 + /// How long ago a unix timestamp was, never negative.
4610 + fn age_of(at: i64) -> i64 {
4611 + let now = std::time::SystemTime::now()
4612 + .duration_since(std::time::UNIX_EPOCH)
4613 + .map_or(at, |since| {
4614 + i64::try_from(since.as_secs()).unwrap_or(i64::MAX)
4615 + });
4616 + now.saturating_sub(at).max(0)
4617 + }
4618 +
4192 4619 /// The library-wide tag queue, as much of it as a described screen needs.
4193 4620 ///
4194 4621 /// One struct where the flow is an enum, and [`Forging`]'s reason again: the
@@ -5206,6 +5633,8 @@
5206 5633 pub queue: &'a dyn Queue,
5207 5634 /// What is being filtered for, for the filter panel.
5208 5635 pub filters: &'a dyn Filters,
5636 + /// The libraries on this machine, for the settings window's Storage section.
5637 + pub storage: &'a dyn Storage,
5209 5638 /// The themes on offer, resolved by the host at startup.
5210 5639 pub themes: &'a [ThemeChoice],
5211 5640 }
@@ -5219,7 +5648,9 @@
5219 5648 filters::routes(queue::routes(forge::routes(edit::routes(
5220 5649 integrity::routes(importing::routes(naming::routes(toolbar::routes(
5221 5650 library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
5222 - export::routes(files::routes(sync::routes(settings::routes(Router::new())))),
5651 + export::routes(files::routes(sync::routes(storage::routes(
5652 + settings::routes(Router::new()),
5653 + )))),
5223 5654 ))))),
5224 5655 )))),
5225 5656 ))))
@@ -34,8 +34,8 @@
34 34
35 35 use super::{
36 36 FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge,
37 - FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromSyncManager,
38 - FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
37 + FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromStorage,
38 + FromSyncManager, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
39 39 };
40 40 use crate::state::BrowserState;
41 41 use crate::ui::theme;
@@ -1463,10 +1463,103 @@
1463 1463 state.locate_missing_loose_files(&folder);
1464 1464 });
1465 1465 }
1466 + // The Storage section. Every one of these is the shipped section's
1467 + // own write: `VaultAction` is the queue the app layer already drains
1468 + // each frame, so the described side asks for exactly what the egui
1469 + // one asked for and nothing new was invented to receive it.
1470 + //
1471 + // The asking is gone from this side, which is the difference worth
1472 + // naming. `ConfirmAction::SwitchLibrary` used to be raised here on
1473 + // `has_in_flight_work`; the described row carries `Act::confirm`
1474 + // instead, so by the time an intent lands the question has been put.
1475 + Intent::SwitchLibrary(path) => {
1476 + state.settings.pending_action = Some(crate::state::VaultAction::SwitchVault(path));
1477 + close_settings(state);
1478 + }
1479 + Intent::RenamingLibrary(target) => state.settings.rename_target = target,
1480 + Intent::RenameLibrary { path, name } => {
1481 + state.settings.pending_action = Some(crate::state::VaultAction::RenameVault {
1482 + path,
1483 + new_name: name,
1484 + });
1485 + }
1486 + Intent::ForgetLibrary(path) => {
1487 + state.settings.pending_action = Some(crate::state::VaultAction::RemoveVault(path));
1488 + }
1489 + Intent::RelocateLibrary { old, new } => {
1490 + state.settings.pending_action = Some(crate::state::VaultAction::RelocateVault {
1491 + old_path: old,
1492 + new_path: new,
1493 + });
1494 + }
1495 + Intent::ScanStorage => {
1496 + state.settings.pending_action = Some(crate::state::VaultAction::ScanStorage);
1497 + }
1498 + Intent::CleanupOrphans => state.cleanup_orphans_now(),
1499 + Intent::BackfillFeatures => state.start_backfill(),
1500 + Intent::VerifyIntegrity => state.verify_store_integrity_now(),
1501 + Intent::DraftLibraryName(name) => state.settings.create_name = name,
1502 + Intent::DraftLibraryFolder(folder) => state.settings.create_path = Some(folder),
1503 + Intent::DraftLibraryStyle(reference_in_place) => {
1504 + state.settings.create_loose_files = reference_in_place;
1505 + }
1506 + Intent::CreateLibrary => {
1507 + if let Some((name, path)) = drafted(state) {
1508 + let loose_files = state.settings.create_loose_files;
1509 + state.settings.pending_action = Some(crate::state::VaultAction::CreateVault {
1510 + name,
1511 + path,
1512 + loose_files,
1513 + });
1514 + discard_draft(state);
1515 + close_settings(state);
1516 + }
1517 + }
1518 + Intent::AddExistingLibrary => {
1519 + if let Some((name, path)) = drafted(state) {
1520 + state.settings.pending_action =
1521 + Some(crate::state::VaultAction::AddExistingVault { name, path });
1522 + discard_draft(state);
1523 + // Both commit paths close Settings: a Create makes the new
1524 + // library active, and an Add Existing typically motivates
1525 + // browsing it straight away.
1526 + close_settings(state);
1527 + }
1528 + }
1529 + Intent::DiscardLibraryDraft => discard_draft(state),
1466 1530 }
1467 1531 }
1468 1532 }
1469 1533
1534 + /// The Add Library form, if it holds enough to act on.
1535 + ///
1536 + /// The route checked this too, and both checks are wanted: the route's is what
1537 + /// keeps a disabled control honest to someone typing the address, and this one
1538 + /// is what keeps the intent honest to a form that emptied between the answer and
1539 + /// the frame that applies it.
1540 + fn drafted(state: &BrowserState) -> Option<(String, std::path::PathBuf)> {
1541 + let name = state.settings.create_name.trim().to_owned();
1542 + let path = state.settings.create_path.clone()?;
1543 + (!name.is_empty()).then_some((name, path))
1544 + }
1545 +
1546 + /// Empty the Add Library form.
1547 + fn discard_draft(state: &mut BrowserState) {
1548 + state.settings.create_name.clear();
1549 + state.settings.create_path = None;
1550 + state.settings.create_loose_files = false;
1551 + }
1552 +
1553 + /// Put the settings window away, and the runtime that was serving it.
1554 + ///
1555 + /// The runtime goes because it holds the address the window was showing, and a
1556 + /// window that closed on a switch should reopen at `/settings` rather than where
1557 + /// it left off.
1558 + fn close_settings(state: &mut BrowserState) {
1559 + state.settings.show_manager = false;
1560 + state.described.settings = None;
1561 + }
1562 +
1470 1563 /// Put a tag on the selected sample, the way the shipped panel does.
1471 1564 ///
1472 1565 /// Validated here rather than in the route, because validation is the app's:
@@ -2238,6 +2331,7 @@
2238 2331 let forge = FromForge { state, intents };
2239 2332 let queue = FromQueue { state, intents };
2240 2333 let filters = FromFilters { state, intents };
2334 + let storage = FromStorage { state, intents };
2241 2335 let panels = Panels {
2242 2336 config: &config,
2243 2337 sync,
@@ -2255,6 +2349,7 @@
2255 2349 forge: &forge,
2256 2350 queue: &queue,
2257 2351 filters: &filters,
2352 + storage: &storage,
2258 2353 themes,
2259 2354 };
2260 2355 super::router()
@@ -38,15 +38,17 @@
38 38 //! | Preview | yes | two booleans in `user_config` |
39 39 //! | Forge | yes | one boolean in `user_config` |
40 40 //! | Display | yes | five booleans, a number and a control |
41 - //! | Storage | **no** | library paths, reachability, relocation: the filesystem |
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 44 //! | Trash | **no** | filesystem sizes and a destructive sweep over them |
45 45 //! | Classifier | **no** | its own model state, and bespoke |
46 46 //!
47 - //! Storage and Trash are the honest kind of "no": they are about files on a
48 - //! disk, and a description that named them would be describing this host's
49 - //! filesystem.
47 + //! Storage was the honest kind of "no" until it was counted. It is described
48 + //! now, in [`storage`](super::storage), and what its header records is that two
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 52 //!
51 53 //! **Advanced was the interesting one, and half of it is answered.** This port
52 54 //! filed "a control that asks the host where to put something and then acts has
@@ -153,7 +155,12 @@
153 155 }
154 156
155 157 /// The whole screen.
156 - fn screen(state: &Panels<'_>) -> Result<Screen, RouteError> {
158 + ///
159 + /// Reachable from [`storage`](super::storage), whose acts all answer with the
160 + /// settings window again: the Storage section is served from its own module and
161 + /// is spliced in below, so the two halves of one screen live where their routes
162 + /// do.
163 + pub(super) fn screen(state: &Panels<'_>) -> Result<Screen, RouteError> {
157 164 let mut body = Slot::new(BODY, RegionKind::Pane)
158 165 .with(Node::page("Settings"))
159 166 .with(Node::section("Appearance"))
@@ -193,6 +200,8 @@
193 200 ))
194 201 .with(Node::Field(Box::new(row_height(state)?)));
195 202
203 + body = super::storage::section(body, state);
204 +
196 205 // Advanced, half of it. See the header: Export Current is describable as of
197 206 // quasi 0.50.0 and Import Theme is not, so the section is what the
198 207 // vocabulary can say rather than all-or-nothing.
@@ -13,13 +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, Editing, Export, Failure,
17 - Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format, Group, Halted,
18 - Holding, Importing, Integrity, Keys, Knob, Library, Measure, Measures, Migrating, Naming,
19 - Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue,
20 - Queued, Reviewed, Sample, Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source,
21 - Spread, Stage, State, Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep,
22 - Sync, Tagged, ThemeChoice, Vault, VaultChoice, Walked, Where, router,
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,
23 24 };
24 25
25 26 /// A config store in memory.
@@ -264,6 +265,7 @@
264 265 forge: &Unforged,
265 266 queue: &Unqueued,
266 267 filters: &Unfiltered,
268 + storage: &OneLibrary,
267 269 themes: &themes,
268 270 };
269 271 router().handle(&state, request)
@@ -429,6 +431,7 @@
429 431 forge: &Unforged,
430 432 queue: &Unqueued,
431 433 filters: &Unfiltered,
434 + storage: &OneLibrary,
432 435 themes: &themes,
433 436 };
434 437 router().handle(&state, request)
@@ -490,6 +493,7 @@
490 493 forge: &Unforged,
491 494 queue: &Unqueued,
492 495 filters: &Unfiltered,
496 + storage: &OneLibrary,
493 497 themes: &themes,
494 498 };
495 499 router().handle(&state, request)
@@ -654,6 +658,7 @@
654 658 forge: &Unforged,
655 659 queue: &Unqueued,
656 660 filters: &Unfiltered,
661 + storage: &OneLibrary,
657 662 themes: &themes,
658 663 };
659 664 let response = router()
@@ -686,11 +691,24 @@
686 691 // it: `columns/reset` is one write to one key, and `theme/export` is not a
687 692 // write at all -- it hands back a file. What this asserts is that no control
688 693 // grew an address of its own, which is the drift it exists to catch.
689 - let settings = table
690 - .iter()
691 - .filter(|(_, path)| path.starts_with("/settings"))
692 - .count();
693 - assert_eq!(settings, 4, "{table:?}");
694 + //
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| {
702 + table
703 + .iter()
704 + .filter(|(_, path)| {
705 + path.starts_with(prefix) && (apart || !path.starts_with("/settings/storage"))
706 + })
707 + .count()
708 + };
709 + assert_eq!(counted("/settings", false), 4, "{table:?}");
710 + assert_eq!(counted("/settings/storage", true), 16, "{table:?}");
711 + assert!(table.contains(&(Method::Post, "/settings/storage/draft/{key}".to_owned())));
694 712 }
695 713
696 714 /// A router call against the settings screen, over a given config store.
@@ -715,6 +733,7 @@
715 733 forge: &Unforged,
716 734 queue: &Unqueued,
717 735 filters: &Unfiltered,
736 + storage: &OneLibrary,
718 737 themes: &themes,
719 738 };
720 739 router().handle(&state, request)
@@ -786,6 +805,7 @@
786 805 forge: &Unforged,
787 806 queue: &Unqueued,
788 807 filters: &Unfiltered,
808 + storage: &OneLibrary,
789 809 themes: &themes,
790 810 };
791 811
@@ -837,6 +857,7 @@
837 857 forge: &Unforged,
838 858 queue: &Unqueued,
839 859 filters: &Unfiltered,
860 + storage: &OneLibrary,
840 861 themes: &themes,
841 862 };
842 863 let refused = router().handle(
@@ -874,6 +895,7 @@
874 895 forge: &Unforged,
875 896 queue: &Unqueued,
876 897 filters: &Unfiltered,
898 + storage: &OneLibrary,
877 899 themes: &themes,
878 900 };
879 901
@@ -934,6 +956,7 @@
934 956 forge: &Unforged,
935 957 queue: &Unqueued,
936 958 filters: &Unfiltered,
959 + storage: &OneLibrary,
937 960 themes: &themes,
938 961 };
939 962 let response = router()
@@ -990,6 +1013,7 @@
990 1013 forge: &Unforged,
991 1014 queue: &Unqueued,
992 1015 filters: &Unfiltered,
1016 + storage: &OneLibrary,
993 1017 themes: &themes,
994 1018 };
995 1019 let response = router()
@@ -1159,6 +1183,7 @@
1159 1183 forge: &Unforged,
1160 1184 queue: &Unqueued,
1161 1185 filters: &Unfiltered,
1186 + storage: &OneLibrary,
1162 1187 themes: &themes,
1163 1188 };
1164 1189 router().handle(&state, request)
@@ -2784,6 +2809,7 @@
2784 2809 forge: &Unforged,
2785 2810 queue: &Unqueued,
2786 2811 filters: &Unfiltered,
2812 + storage: &OneLibrary,
2787 2813 themes: &themes,
2788 2814 };
2789 2815 router().handle(&state, request)
@@ -3338,6 +3364,7 @@
3338 3364 forge: &Unforged,
3339 3365 queue: &Unqueued,
3340 3366 filters: &Unfiltered,
3367 + storage: &OneLibrary,
3341 3368 themes: &themes,
3342 3369 };
3343 3370 router().handle(&state, request)
@@ -3727,6 +3754,7 @@
3727 3754 forge: &Unforged,
3728 3755 queue: &Unqueued,
3729 3756 filters: &Unfiltered,
3757 + storage: &OneLibrary,
3730 3758 themes: &themes,
3731 3759 };
3732 3760 router().handle(&state, request)
@@ -4103,6 +4131,7 @@
4103 4131 forge: &Unforged,
4104 4132 queue: &Unqueued,
4105 4133 filters: &Unfiltered,
4134 + storage: &OneLibrary,
4106 4135 themes: &themes,
4107 4136 };
4108 4137 router().handle(&state, request)
@@ -4524,6 +4553,7 @@
4524 4553 forge: &Unforged,
4525 4554 queue: &Unqueued,
4526 4555 filters: &Unfiltered,
4556 + storage: &OneLibrary,
4527 4557 themes: &themes,
4528 4558 };
4529 4559 router().handle(&state, request)
@@ -5011,6 +5041,7 @@
5011 5041 forge: &Unforged,
5012 5042 queue: &Unqueued,
5013 5043 filters: &Unfiltered,
5044 + storage: &OneLibrary,
5014 5045 themes: &themes,
5015 5046 };
5016 5047 router().handle(&state, request)
@@ -5678,6 +5709,74 @@
5678 5709 fn purge(&self) {}
5679 5710 }
5680 5711
5712 + /// One library, open and reachable, with nothing scanned and no form started.
5713 + ///
5714 + /// The quiet fixture every other screen's test takes, matching [`Sound`] and
5715 + /// [`Unfiltered`]: enough for the settings screen to render its Storage section
5716 + /// without the test having to care that it does. The section's own tests use
5717 + /// [`FakeStorage`], which records.
5718 + struct OneLibrary;
5719 +
5720 + impl Storage for OneLibrary {
5721 + fn libraries(&self) -> Vec<LibraryEntry> {
5722 + vec![LibraryEntry {
5723 + name: "Samples".to_owned(),
5724 + shown: "~/Samples".to_owned(),
5725 + path: "/home/max/Samples".to_owned(),
5726 + active: true,
5727 + reachable: true,
5728 + }]
5729 + }
5730 +
5731 + fn scan(&self) -> Option<Scan> {
5732 + None
5733 + }
5734 +
5735 + fn scanning(&self) -> bool {
5736 + false
5737 + }
5738 +
5739 + fn backfilling(&self) -> bool {
5740 + false
5741 + }
5742 +
5743 + fn busy(&self) -> bool {
5744 + false
5745 + }
5746 +
5747 + fn loose_files(&self) -> bool {
5748 + false
5749 + }
5750 +
5751 + fn interrupting(&self) -> bool {
5752 + false
5753 + }
5754 +
5755 + fn renaming(&self) -> Option<usize> {
5756 + None
5757 + }
5758 +
5759 + fn draft(&self) -> Draft {
5760 + Draft::default()
5761 + }
5762 +
5763 + fn open(&self, _at: usize) {}
5764 + fn rename_row(&self, _at: Option<usize>) {}
5765 + fn rename(&self, _at: usize, _name: &str) {}
5766 + fn forget(&self, _at: usize) {}
5767 + fn relocate(&self, _at: usize, _folder: &str) {}
5768 + fn rescan(&self) {}
5769 + fn cleanup_orphans(&self) {}
5770 + fn backfill(&self) {}
5771 + fn verify(&self) {}
5772 + fn draft_name(&self, _name: &str) {}
5773 + fn draft_folder(&self, _folder: &str) {}
5774 + fn draft_style(&self, _reference_in_place: bool) {}
5775 + fn create(&self) {}
5776 + fn add_existing(&self) {}
5777 + fn discard(&self) {}
5778 + }
5779 +
5681 5780 /// A namer in memory, recording what was asked of it and refusing on demand.
5682 5781 #[derive(Default)]
5683 5782 struct FakeNaming {
@@ -5799,6 +5898,7 @@
5799 5898 forge: &Unforged,
5800 5899 queue: &Unqueued,
5801 5900 filters: &Unfiltered,
5901 + storage: &OneLibrary,
5802 5902 themes: &themes,
5803 5903 };
5804 5904 router().handle(&state, request)
@@ -5961,6 +6061,7 @@
5961 6061 forge: &Unforged,
5962 6062 queue: &Unqueued,
5963 6063 filters: &Unfiltered,
6064 + storage: &OneLibrary,
5964 6065 themes: &themes,
5965 6066 };
5966 6067 router().handle(&state, request)
@@ -6267,6 +6368,7 @@
6267 6368 forge: &Unforged,
6268 6369 queue: &Unqueued,
6269 6370 filters: &Unfiltered,
6371 + storage: &OneLibrary,
6270 6372 themes: &themes,
6271 6373 };
6272 6374 router().handle(&state, request)
@@ -6350,6 +6452,7 @@
6350 6452 forge: &Unforged,
6351 6453 queue: &Unqueued,
6352 6454 filters: &Unfiltered,
6455 + storage: &OneLibrary,
6353 6456 themes: &themes,
6354 6457 };
6355 6458 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -6531,6 +6634,7 @@
6531 6634 forge: &Unforged,
6532 6635 queue: &Unqueued,
6533 6636 filters: &Unfiltered,
6637 + storage: &OneLibrary,
6534 6638 themes: &themes,
6535 6639 };
6536 6640 router().handle(&state, request)
@@ -8108,6 +8212,7 @@
8108 8212 forge,
8109 8213 queue: &Unqueued,
8110 8214 filters: &Unfiltered,
8215 + storage: &OneLibrary,
8111 8216 themes: &themes,
8112 8217 };
8113 8218 router().handle(&state, request)
@@ -8688,6 +8793,7 @@
8688 8793 forge: &Unforged,
8689 8794 queue: &Unqueued,
8690 8795 filters,
8796 + storage: &OneLibrary,
8691 8797 themes: &themes,
8692 8798 };
8693 8799 router().handle(&state, request)
@@ -8826,6 +8932,7 @@
8826 8932 forge: &Unforged,
8827 8933 queue,
8828 8934 filters: &Unfiltered,
8935 + storage: &OneLibrary,
8829 8936 themes: &themes,
8830 8937 };
8831 8938 router().handle(&state, request)
@@ -9343,3 +9450,518 @@
9343 9450 .expect("the route answered");
9344 9451 assert_eq!(filters.asked(), ["key:C# minor"]);
9345 9452 }
9453 +
9454 + /// A library registry in memory, recording what was asked of it.
9455 + #[derive(Default)]
9456 + struct FakeStorage {
9457 + libraries: Vec<LibraryEntry>,
9458 + scan: Option<Scan>,
9459 + scanning: bool,
9460 + backfilling: bool,
9461 + busy: bool,
9462 + loose_files: bool,
9463 + interrupting: bool,
9464 + renaming: Option<usize>,
9465 + draft: Draft,
9466 + asked: RefCell<Vec<String>>,
9467 + }
9468 +
9469 + impl FakeStorage {
9470 + /// One library open, one offline, and nothing else going on.
9471 + fn two() -> Self {
9472 + Self {
9473 + libraries: vec![
9474 + LibraryEntry {
9475 + name: "Samples".to_owned(),
9476 + shown: "~/Samples".to_owned(),
9477 + path: "/home/max/Samples".to_owned(),
9478 + active: true,
9479 + reachable: true,
9480 + },
9481 + LibraryEntry {
9482 + name: "Archive".to_owned(),
9483 + shown: "/mnt/slow/Archive".to_owned(),
9484 + path: "/mnt/slow/Archive".to_owned(),
9485 + active: false,
9486 + reachable: false,
9487 + },
9488 + ],
9489 + ..Self::default()
9490 + }
9491 + }
9492 +
9493 + /// What was asked of it, in order.
9494 + fn asked(&self) -> Vec<String> {
9495 + self.asked.borrow().clone()
9496 + }
9497 +
9498 + fn note(&self, what: impl Into<String>) {
9499 + self.asked.borrow_mut().push(what.into());
9500 + }
9501 + }
9502 +
9503 + impl Storage for FakeStorage {
9504 + fn libraries(&self) -> Vec<LibraryEntry> {
9505 + self.libraries.clone()
9506 + }
9507 +
9508 + fn scan(&self) -> Option<Scan> {
9509 + self.scan
9510 + }
9511 +
9512 + fn scanning(&self) -> bool {
9513 + self.scanning
9514 + }
9515 +
9516 + fn backfilling(&self) -> bool {
9517 + self.backfilling
9518 + }
9519 +
9520 + fn busy(&self) -> bool {
9521 + self.busy
9522 + }
9523 +
9524 + fn loose_files(&self) -> bool {
9525 + self.loose_files
9526 + }
9527 +
9528 + fn interrupting(&self) -> bool {
9529 + self.interrupting
9530 + }
9531 +
9532 + fn renaming(&self) -> Option<usize> {
9533 + self.renaming
9534 + }
9535 +
9536 + fn draft(&self) -> Draft {
9537 + self.draft.clone()
9538 + }
9539 +
9540 + fn open(&self, at: usize) {
9541 + self.note(format!("open {at}"));
9542 + }
9543 +
9544 + fn rename_row(&self, at: Option<usize>) {
9545 + self.note(match at {
9546 + Some(at) => format!("rename_row {at}"),
9547 + None => "rename_row none".to_owned(),
9548 + });
9549 + }
9550 +
9551 + fn rename(&self, at: usize, name: &str) {
9552 + self.note(format!("rename {at} {name}"));
9553 + }
9554 +
9555 + fn forget(&self, at: usize) {
9556 + self.note(format!("forget {at}"));
9557 + }
9558 +
9559 + fn relocate(&self, at: usize, folder: &str) {
9560 + self.note(format!("relocate {at} {folder}"));
9561 + }
9562 +
9563 + fn rescan(&self) {
9564 + self.note("rescan");
9565 + }
9566 +
9567 + fn cleanup_orphans(&self) {
9568 + self.note("orphans");
9569 + }
9570 +
9571 + fn backfill(&self) {
9572 + self.note("backfill");
9573 + }
9574 +
9575 + fn verify(&self) {
9576 + self.note("verify");
9577 + }
9578 +
9579 + fn draft_name(&self, name: &str) {
9580 + self.note(format!("draft_name {name}"));
9581 + }
9582 +
9583 + fn draft_folder(&self, folder: &str) {
9584 + self.note(format!("draft_folder {folder}"));
9585 + }
9586 +
9587 + fn draft_style(&self, reference_in_place: bool) {
9588 + self.note(format!("draft_style {reference_in_place}"));
9589 + }
9590 +
9591 + fn create(&self) {
9592 + self.note("create");
9593 + }
9594 +
9595 + fn add_existing(&self) {
9596 + self.note("add_existing");
9597 + }
9598 +
9599 + fn discard(&self) {
9600 + self.note("discard");
9601 + }
9602 + }
9603 +
9604 + /// A router call against this library registry.
9605 + fn storing(storage: &FakeStorage, request: Request) -> Result<Response, quasi_router::RouteError> {
9606 + let store = Store::default();
9607 + let sync = Offline;
9608 + let files = FakeFiles::default();
9609 + let themes = themes();
9610 + let state = Panels {
9611 + detail: &Unfocused,
9612 + bulk: &Unchosen,
9613 + shell: &Quiet,
9614 + library: &Empty,
9615 + bar: &Still,
9616 + config: &store,
9617 + sync: &sync,
9618 + files: &files,
9619 + export: &Idle,
9620 + naming: &Unnamed,
9621 + importing: &NoImport,
9622 + integrity: &Sound,
9623 + editor: &Unedited,
9624 + forge: &Unforged,
9625 + queue: &Unqueued,
9626 + filters: &Unfiltered,
9627 + storage,
9628 + themes: &themes,
9629 + };
9630 + router().handle(&state, request)
9631 + }
9632 +
9633 + /// The settings screen this registry produces.
9634 + fn stored(storage: &FakeStorage) -> Screen {
9635 + screen_of(&storing(storage, Request::get("/settings")).expect("answered")).clone()
9636 + }
9637 +
9638 + /// What the Add Library form says is wrong with the name it holds.
9639 + fn name_error(storage: &FakeStorage) -> Option<String> {
9640 + nodes_deep(&stored(storage))
9641 + .into_iter()
9642 + .find_map(|node| match node {
9643 + Node::Field(field) if field.name == "create_name" => Some(field.error.clone()),
9644 + _ => None,
9645 + })
9646 + .expect("the form asks for a name")
9647 + }
9648 +
9649 + /// The library rows the settings screen describes.
9650 + fn library_rows(storage: &FakeStorage) -> Vec<quasi_router::Row> {
Lines truncated
@@ -1,0 +1,620 @@
1 + //! The settings window's Storage section, described: the libraries on this
2 + //! machine, what they hold, and the four things maintenance can do to the one
3 + //! that is open.
4 + //!
5 + //! The largest thing the settings flip left out. `draw_storage_section` was 430
6 + //! of `ui/settings_panel.rs`'s 1,182 lines and went with the file, under
7 + //! `da48cb6d`: a section goes away at the flip and comes back when it is
8 + //! described. This is that.
9 + //!
10 + //! # The refusal it was carrying, counted rather than restated
11 + //!
12 + //! [`settings`](super::settings)'s header ruled this section out as "library
13 + //! paths, reachability, relocation: the filesystem", and the flip's own task
14 + //! said to count what was actually missing before repeating that. Counted, it
15 + //! was three things and two of them had already been answered elsewhere:
16 + //!
17 + //! - **Asking for a place** — Locate on an offline row, and Choose folder on the
18 + //! Add Library form. `ec92f9cb` shipped [`Outcome::Locate`] in quasi 0.60 and
19 + //! the export destination took it first. These are its second and third
20 + //! consumers here.
21 + //! - **Reachability and the scan's numbers** — host facts the app has already
22 + //! resolved before a frame runs, so they arrive on [`Storage`](super::Storage)
23 + //! the way the themes arrive on `S`. No handler reads a disk.
24 + //! - **The path itself** — a string the host spells and collapses. The
25 + //! description never parses one, and `~` is applied where `dirs::home_dir`
26 + //! can be called.
27 + //!
28 + //! Nothing was left. The refusal was true when it was written and had been
29 + //! false for three days, which is the argument for counting: a "no" recorded
30 + //! against a vocabulary keeps its wording after the vocabulary moves.
31 + //!
32 + //! # The row's click is not described, and its confirmation is
33 + //!
34 + //! The shipped row was clickable, and clicking it asked
35 + //! `ConfirmAction::SwitchLibrary` first — but only when
36 + //! [`interrupting`](super::Storage::interrupting) said there was work to lose.
37 + //! A [`Row::activate`] carries an [`Action`] and not an [`Act`], so it has
38 + //! nowhere to put a confirmation. Rather than describe a click that could skip
39 + //! the asking, the row activates only when switching is free, and the `Open`
40 + //! act beside it is what carries [`Act::confirm`] when it is not. Same rule as
41 + //! the shipped panel, said on the control, and it is the fourth
42 + //! `ConfirmAction` variant this port has replaced with a builder method.
43 + //!
44 + //! # What the form's three fields cost, and what has been paid off
45 + //!
46 + //! `bebfd112` filed the Add Library form as the site where
47 + //! `makeover_immediate::group` could not be used: one describable field out of
48 + //! three, because the folder picker was a button and a path and the storage
49 + //! style was a hand-rolled radio pair. Both halves have closed since —
50 + //! `FieldKind::Radio` in makeover-layout 0.8.1, which the shipped form had
51 + //! already taken, and [`Outcome::Locate`] for the picker — so all three are
52 + //! described here and the form is one question after another with nothing
53 + //! hand-rolled between them.
54 + //!
55 + //! The name is remembered per keystroke rather than held by the renderer, which
56 + //! looks like a cost and is a requirement: the picker leaves and comes back, and
57 + //! a name living only in the form would not survive the round trip. That is the
58 + //! same `changes`-writes-through shape every other control on this screen has.
59 + //!
60 + //! # THE FINDING: an act has no standing help
61 + //!
62 + //! Six controls in this section carried an `on_hover_text`, and three of them
63 + //! said something the label does not: what Cleanup orphans does to other synced
64 + //! devices, that Backfill yields to an analysis you start, that Verify reports
65 + //! into the status line. [`Field::hint`](quasi_router::Field::hint) is standing
66 + //! help about an *answer* and [`Act::confirm`] is a question asked before a
67 + //! *write*; there is nothing that means "standing help about this control".
68 + //!
69 + //! So the three are prose beside the act instead, which is what the storage
70 + //! style's own hint argued for in the shipped form — help that is shown rather
71 + //! than hunted for. The other three restated their labels and are gone. Whether
72 + //! `Act` should carry a hint is a real question and this is its first consumer;
73 + //! a hover is not the shape to ask for, since half the hosts have no pointer.
74 + //!
75 + //! [`Act`]: quasi_router::Act
76 + //! [`Act::confirm`]: quasi_router::Act::confirm
77 + //! [`Action`]: quasi_router::Action
78 + //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
79 + //! [`Row::activate`]: quasi_router::Row::activate
80 +
81 + use quasi_router::layout::{FieldKind, Tone};
82 + use quasi_router::{
83 + Act, Action, Choice, Field, Locating, Node, Request, Response, RouteError, Router, Row, Slot,
84 + Tag,
85 + };
86 +
87 + use super::Panels;
88 +
89 + /// The name a picked folder comes back under.
90 + const FOLDER: &str = "folder";
91 +
92 + /// The name a typed library name is submitted under.
93 + const NAME: &str = "name";
94 +
95 + /// What switching costs when there is work in flight.
96 + const INTERRUPT: &str =
97 + "An import or bulk action is running and will be cancelled. Open this library anyway?";
98 +
99 + /// What removing a library does and does not do.
100 + const FORGET: &str =
101 + "Remove this library from the list? Its files and database are left where they are.";
102 +
103 + /// The one fact that applies to the storage-style question rather than to
104 + /// either answer.
105 + const STYLE_HINT: &str = "Cannot be changed after the library is created.";
106 +
107 + /// Register the Storage section's routes.
108 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
109 + router
110 + .post("/settings/storage/open/{at}", open)
111 + .post("/settings/storage/rename/{at}", rename_row)
112 + .post("/settings/storage/rename/{at}/save", rename)
113 + .post("/settings/storage/cancel-rename", cancel_rename)
114 + .post("/settings/storage/forget/{at}", forget)
115 + .post("/settings/storage/locate/{at}", locate)
116 + .post("/settings/storage/relocate/{at}", relocate)
117 + .post("/settings/storage/scan", scan)
118 + .post("/settings/storage/orphans", orphans)
119 + .post("/settings/storage/backfill", backfill)
120 + .post("/settings/storage/verify", verify)
121 + .post("/settings/storage/folder", folder)
122 + .post("/settings/storage/draft/{key}", draft)
123 + .post("/settings/storage/create", create)
124 + .post("/settings/storage/add", add_existing)
125 + .post("/settings/storage/discard", discard)
126 + }
127 +
128 + /// `POST /settings/storage/open/{at}`
129 + fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
130 + state.storage.open(row(&request)?);
131 + settled(state)
132 + }
133 +
134 + /// `POST /settings/storage/rename/{at}`
135 + ///
136 + /// Shows the form; it does not rename anything. The form appears on the next
137 + /// frame, because the intent that opens it lands after this answer was built —
138 + /// which is what `Runtime::reload` and `Described::stale` are for.
139 + fn rename_row(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
140 + state.storage.rename_row(Some(row(&request)?));
141 + settled(state)
142 + }
143 +
144 + /// `POST /settings/storage/rename/{at}/save`
145 + ///
146 + /// An empty name closes the form and changes nothing, which is the rule
147 + /// [`naming`](super::naming) settled for the four modals and the same one the
148 + /// shipped inline form followed.
149 + fn rename(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
150 + let at = row(&request)?;
151 + let typed = request.payload.get(NAME).unwrap_or_default().trim();
152 + if !typed.is_empty() {
153 + state.storage.rename(at, typed);
154 + }
155 + state.storage.rename_row(None);
156 + settled(state)
157 + }
158 +
159 + /// `POST /settings/storage/cancel-rename`
160 + fn cancel_rename(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
161 + state.storage.rename_row(None);
162 + settled(state)
163 + }
164 +
165 + /// `POST /settings/storage/forget/{at}`
166 + fn forget(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
167 + state.storage.forget(row(&request)?);
168 + settled(state)
169 + }
170 +
171 + /// `POST /settings/storage/locate/{at}`
172 + ///
173 + /// Second consumer of [`Outcome::Locate`](quasi_router::Outcome::Locate) on this
174 + /// host. The answer comes back to [`relocate`] with the folder under [`FOLDER`],
175 + /// and a reader who backs out of the picker has answered nothing.
176 + fn locate(_state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
177 + let at = row(&request)?;
178 + Ok(Response::locate(Locating::folder(
179 + "Locate library directory",
180 + Action::post(format!("/settings/storage/relocate/{at}")),
181 + FOLDER,
182 + )))
183 + }
184 +
185 + /// `POST /settings/storage/relocate/{at}`
186 + fn relocate(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
187 + let at = row(&request)?;
188 + let folder = request.payload.get(FOLDER).unwrap_or_default();
189 + if !folder.is_empty() {
190 + state.storage.relocate(at, folder);
191 + }
192 + settled(state)
193 + }
194 +
195 + /// `POST /settings/storage/scan`
196 + fn scan(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
197 + state.storage.rescan();
198 + settled(state)
199 + }
200 +
201 + /// `POST /settings/storage/orphans`
202 + fn orphans(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
203 + state.storage.cleanup_orphans();
204 + settled(state)
205 + }
206 +
207 + /// `POST /settings/storage/backfill`
208 + fn backfill(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
209 + state.storage.backfill();
210 + settled(state)
211 + }
212 +
213 + /// `POST /settings/storage/verify`
214 + fn verify(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
215 + state.storage.verify();
216 + settled(state)
217 + }
218 +
219 + /// `POST /settings/storage/folder`
220 + ///
221 + /// Third consumer of [`Outcome::Locate`](quasi_router::Outcome::Locate), and the
222 + /// form shape of it rather than the act shape: the answer goes to the route that
223 + /// remembers the folder, and the form redraws with the path beside the control.
224 + fn folder(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
225 + Ok(Response::locate(Locating::folder(
226 + "Choose folder",
227 + Action::post("/settings/storage/draft/folder"),
228 + FOLDER,
229 + )))
230 + }
231 +
232 + /// `POST /settings/storage/draft/{key}`
233 + ///
234 + /// One route for the form's three questions, the same shape
235 + /// [`settings`](super::settings) uses for every control it owns. An undeclared
236 + /// key is a `NotFound` rather than a silent no-op: the address is reachable by
237 + /// typing.
238 + fn draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
239 + let key = request.captures.require("key")?;
240 + match key {
241 + NAME => state
242 + .storage
243 + .draft_name(request.payload.get(NAME).unwrap_or_default()),
244 + FOLDER => {
245 + let folder = request.payload.get(FOLDER).unwrap_or_default();
246 + if !folder.is_empty() {
247 + state.storage.draft_folder(folder);
248 + }
249 + }
250 + "style" => {
251 + let style = request.payload.get("style").unwrap_or_default();
252 + state.storage.draft_style(style == "reference");
253 + }
254 + _ => return Err(RouteError::not_found("no such field")),
255 + }
256 + settled(state)
257 + }
258 +
259 + /// `POST /settings/storage/create`
260 + fn create(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
261 + if !state.storage.draft().ready() {
262 + return Err(RouteError::not_found("the form is not finished"));
263 + }
264 + state.storage.create();
265 + settled(state)
266 + }
267 +
268 + /// `POST /settings/storage/add`
269 + fn add_existing(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
270 + if !state.storage.draft().ready() {
271 + return Err(RouteError::not_found("the form is not finished"));
272 + }
273 + state.storage.add_existing();
274 + settled(state)
275 + }
276 +
277 + /// `POST /settings/storage/discard`
278 + fn discard(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
279 + state.storage.discard();
280 + settled(state)
281 + }
282 +
283 + /// The settings window again, which is what every act here answers with.
284 + fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
285 + Ok(super::settings::screen(state)?.into())
286 + }
287 +
288 + /// The row an address names.
289 + fn row(request: &Request) -> Result<usize, RouteError> {
290 + request
291 + .captures
292 + .require("at")?
293 + .parse()
294 + .map_err(|_| RouteError::not_found("that is not a row"))
295 + }
296 +
297 + /// The whole section, added to the settings body.
298 + pub(super) fn section(body: Slot, state: &Panels<'_>) -> Slot {
299 + let mut body = body
300 + .with(Node::section("Storage"))
301 + .with(Node::text(
302 + "Each library is an independent sample collection with its own database and files. A library can contain multiple vaults (top-level browse buckets).",
303 + ))
304 + .with(libraries(state));
305 +
306 + if let Some(at) = state.storage.renaming() {
307 + body = body.with(rename_form(at, state));
308 + }
309 +
310 + body = maintenance(body, state);
311 +
312 + if state.storage.loose_files() {
313 + body = body.with(Node::Text {
314 + text: "This library uses loose-files mode. Samples are referenced in place, not duplicated.".to_owned(),
315 + tone: Tone::Warning,
316 + });
317 + }
318 +
319 + add_library(body, state)
320 + }
321 +
322 + /// The libraries, one row each.
323 + fn libraries(state: &Panels<'_>) -> Node {
324 + let all = state.storage.libraries();
325 + let scan = state.storage.scan();
326 + let interrupting = state.storage.interrupting();
327 + let mut rows = Vec::with_capacity(all.len());
328 +
329 + for (at, entry) in all.iter().enumerate() {
330 + // The offline row's badge carried the last-known path on hover in the
331 + // shipped panel. A hover is a host's; the path is said outright here,
332 + // which is what a terminal or a screen reader gets to keep.
333 + let mut row = Row::new(&entry.name).secondary(entry.shown.clone());
334 + if entry.active {
335 + row = row.token(Tag::badge("active"));
336 + } else if !entry.reachable {
337 + row = row
338 + .token(Tag::badge("offline"))
339 + .meta(format!("Last known path: {}", entry.path));
340 + }
341 +
342 + // Only the open library has been counted: the scan reads the database
343 + // that is open, and the shipped rows were path-only for the rest.
344 + if entry.active
345 + && let Some(scan) = scan
346 + {
347 + row = row.meta(format!(
348 + "{} samples \u{b7} {}",
349 + scan.samples,
350 + bytes(scan.total_bytes),
351 + ));
352 + }
353 +
354 + let switchable = !entry.active && entry.reachable;
355 + if switchable {
356 + let mut act = Act::new("Open", Action::post(format!("/settings/storage/open/{at}")));
357 + if interrupting {
358 + act = act.confirm(INTERRUPT);
359 + } else {
360 + // See the module header: the click is only offered where it
361 + // cannot skip an asking.
362 + row = row.activate(Action::post(format!("/settings/storage/open/{at}")));
363 + }
364 + row = row.act(act);
365 + }
366 +
367 + row = row.act(Act::new(
368 + "Rename",
369 + Action::post(format!("/settings/storage/rename/{at}")),
370 + ));
371 +
372 + if !entry.active && !entry.reachable {
373 + row = row.act(Act::new(
374 + "Locate",
375 + Action::post(format!("/settings/storage/locate/{at}")),
376 + ));
377 + }
378 +
379 + if !entry.active {
380 + row = row.act(
381 + Act::new(
382 + "Remove",
383 + Action::post(format!("/settings/storage/forget/{at}")),
384 + )
385 + .tone(Tone::Danger)
386 + .confirm(FORGET),
387 + );
388 + }
389 +
390 + rows.push(row);
391 + }
392 +
393 + Node::List { rows, more: None }
394 + }
395 +
396 + /// The inline rename, against whichever row is being renamed.
397 + fn rename_form(at: usize, state: &Panels<'_>) -> Node {
398 + let current = state
399 + .storage
400 + .libraries()
401 + .get(at)
402 + .map(|entry| entry.name.clone())
403 + .unwrap_or_default();
404 +
405 + Node::Form {
406 + fields: vec![Field::new(FieldKind::Text, NAME, "New name").value(current)],
407 + submit: "Save".to_owned(),
408 + action: Action::post(format!("/settings/storage/rename/{at}/save")),
409 + }
410 + }
411 +
412 + /// Scan, and the three maintenance passes over the open library.
413 + ///
414 + /// Each of the three says it is running by being disabled rather than by
415 + /// swapping its label, which is the shipped busy state minus the spinner: a
416 + /// spinner is a renderer's way of drawing "working", and every host has one or
417 + /// has something better.
418 + fn maintenance(body: Slot, state: &Panels<'_>) -> Slot {
419 + let scanning = state.storage.scanning();
420 + let mut scan_act = Act::new(
421 + if scanning { "Scanning..." } else { "Scan" },
422 + Action::post("/settings/storage/scan"),
423 + );
424 + if scanning {
425 + scan_act = scan_act.disabled();
426 + }
427 + let mut body = body.with(Node::Act(scan_act));
428 +
429 + if let Some(scan) = state.storage.scan() {
430 + body = body.with(Node::text(format!(
431 + "{} samples, {} total, {} database",
432 + scan.samples,
433 + bytes(scan.total_bytes),
434 + bytes(scan.db_bytes),
435 + )));
436 + let (age, stale) = scan_age(scan.age_secs);
437 + body = body.with(Node::Text {
438 + text: age,
439 + tone: if stale { Tone::Warning } else { Tone::Neutral },
440 + });
441 + }
442 +
443 + body = body
444 + .with(Node::text(
445 + "Free disk by deleting samples no longer referenced anywhere in the library. Local-only: other synced devices keep their own copies.",
446 + ))
447 + .with(Node::Act(Act::new(
448 + "Cleanup orphans",
449 + Action::post("/settings/storage/orphans"),
450 + )));
451 +
452 + let backfilling = state.storage.backfilling();
453 + let mut backfill = Act::new(
454 + if backfilling {
455 + "Backfilling audio features..."
456 + } else {
457 + "Backfill audio features"
458 + },
459 + Action::post("/settings/storage/backfill"),
460 + );
461 + if backfilling {
462 + backfill = backfill.disabled();
463 + }
464 + body = body
465 + .with(Node::text(
466 + "Compute the audio feature data used by tag suggestions for samples that don't have it yet. Runs in the background: keep working; it yields to any analysis you start and resumes later.",
467 + ))
468 + .with(Node::Act(backfill));
469 +
470 + let mut integrity = Act::new(
471 + "Verify library integrity",
472 + Action::post("/settings/storage/verify"),
473 + );
474 + if state.storage.busy() {
475 + integrity = integrity.disabled();
476 + }
477 + body
478 + .with(Node::text(
479 + "Re-hash every stored sample and confirm its bytes still match its content address. Catches silent on-disk corruption. Runs in the background: the result appears in the status line.",
480 + ))
481 + .with(Node::Act(integrity))
482 + }
483 +
484 + /// The Add Library form: a name, a folder, and how samples are stored.
485 + fn add_library(body: Slot, state: &Panels<'_>) -> Slot {
486 + let draft = state.storage.draft();
487 +
488 + // The error the shipped form left unexplained: a folder chosen and no name,
489 + // where Create New disabled itself and said nothing about why.
490 + let mut name = Field::new(FieldKind::Text, "create_name", "Name")
491 + .value(draft.name.clone())
492 + .changes(Action::post("/settings/storage/draft/name"));
493 + name.required = true;
494 + if draft.folder.is_some() && draft.name.trim().is_empty() {
495 + name = name.error("A library needs a name.");
496 + }
497 +
498 + let mut body = body
499 + .with(Node::section("Add Library"))
500 + .with(Node::Field(Box::new(name)))
Lines truncated