max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+1364 insertions,
-24 deletions
| @@ -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