max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+686 insertions,
-67 deletions
| @@ -30,6 +30,7 @@ | |||
| 30 | 30 | //! | [`Trash`] | [`trash`] | an [`Intent`], applied after the frame | | |
| 31 | 31 | //! | [`Classifier`] | [`classifier`] | an [`Intent`], applied after the frame | | |
| 32 | 32 | //! | [`Licence`] | [`licence`] | an [`Intent`], applied after the frame | | |
| 33 | + | //! | [`Advanced`] | [`advanced`] | an [`Intent`], applied after the frame | | |
| 33 | 34 | //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host | | |
| 34 | 35 | //! | |
| 35 | 36 | //! The themes are the settled rule from goingson's settings port applied first | |
| @@ -134,6 +135,7 @@ | |||
| 134 | 135 | // a choice made here. | |
| 135 | 136 | #![allow(clippy::needless_pass_by_value)] | |
| 136 | 137 | ||
| 138 | + | pub mod advanced; | |
| 137 | 139 | pub mod bulk; | |
| 138 | 140 | pub mod classifier; | |
| 139 | 141 | pub mod detail; | |
| @@ -939,6 +941,12 @@ | |||
| 939 | 941 | RestoreSample(String), | |
| 940 | 942 | /// Put this machine's id on the clipboard. | |
| 941 | 943 | CopyMachineId, | |
| 944 | + | /// Read a theme file and add it to the custom themes. | |
| 945 | + | ImportTheme(std::path::PathBuf), | |
| 946 | + | /// Build the library mirror's symlink tree, or take it down. | |
| 947 | + | EnableMirror(bool), | |
| 948 | + | /// Build it somewhere else. | |
| 949 | + | MirrorPath(std::path::PathBuf), | |
| 942 | 950 | /// Give the licence key up on this machine. | |
| 943 | 951 | Deactivate, | |
| 944 | 952 | /// Begin authoring a rule. | |
| @@ -5985,6 +5993,99 @@ | |||
| 5985 | 5993 | } | |
| 5986 | 5994 | } | |
| 5987 | 5995 | ||
| 5996 | + | /// The rest of Settings > Advanced: importing a theme, and the library mirror. | |
| 5997 | + | /// | |
| 5998 | + | /// The sixteenth narrow trait and the last of the five sections the flip left | |
| 5999 | + | /// behind. Both writes are intents, and both go through a `&mut` method the app | |
| 6000 | + | /// already has. | |
| 6001 | + | /// | |
| 6002 | + | /// # The mirror is a host fact twice over | |
| 6003 | + | /// | |
| 6004 | + | /// It exists only on unix (`vfs_mirror` builds a symlink tree), and the shipped | |
| 6005 | + | /// section said so with `#[cfg(unix)]` around the drawing. A description cannot | |
| 6006 | + | /// carry a `cfg` and should not: whether this machine can do a thing is the | |
| 6007 | + | /// host's to answer, so [`mirror`](Self::mirror) is `None` where there is no | |
| 6008 | + | /// mirror to have and the section simply is not described there. | |
| 6009 | + | /// | |
| 6010 | + | /// The path arrives collapsed for the same reason a library's does — see | |
| 6011 | + | /// [`LibraryEntry::shown`] — and in full beside it, because the reader may need | |
| 6012 | + | /// to know exactly where the tree will be built. | |
| 6013 | + | pub trait Advanced { | |
| 6014 | + | /// The library mirror, where this host has one. | |
| 6015 | + | fn mirror(&self) -> Option<Mirror>; | |
| 6016 | + | ||
| 6017 | + | /// Read a theme file and add it to the custom themes. | |
| 6018 | + | fn import_theme(&self, path: &str); | |
| 6019 | + | ||
| 6020 | + | /// Build the symlink tree, or take it down. | |
| 6021 | + | fn enable_mirror(&self, enabled: bool); | |
| 6022 | + | ||
| 6023 | + | /// Build it somewhere else. | |
| 6024 | + | fn set_mirror_path(&self, path: &str); | |
| 6025 | + | } | |
| 6026 | + | ||
| 6027 | + | /// The library mirror, as the description names it. | |
| 6028 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 6029 | + | pub struct Mirror { | |
| 6030 | + | /// Whether the symlink tree is being kept. | |
| 6031 | + | pub enabled: bool, | |
| 6032 | + | /// Where it lives, with the home prefix collapsed. | |
| 6033 | + | pub shown: String, | |
| 6034 | + | /// Where it lives, in full. | |
| 6035 | + | pub path: String, | |
| 6036 | + | /// What went wrong with the last change, if anything did. | |
| 6037 | + | /// | |
| 6038 | + | /// Worth describing rather than dropping: these writes fail in ways that | |
| 6039 | + | /// leave the session behaving as asked and the setting not surviving a | |
| 6040 | + | /// restart, which is a state a checkbox on its own cannot tell you about. | |
| 6041 | + | pub failed: Option<String>, | |
| 6042 | + | } | |
| 6043 | + | ||
| 6044 | + | /// The app's advanced settings, as the narrow thing the section borrows. | |
| 6045 | + | pub struct FromAdvanced<'a> { | |
| 6046 | + | /// What the app has loaded. | |
| 6047 | + | pub state: &'a crate::state::BrowserState, | |
| 6048 | + | /// What the described screen asked for, applied after the frame. | |
| 6049 | + | pub intents: &'a std::cell::RefCell<Vec<Intent>>, | |
| 6050 | + | } | |
| 6051 | + | ||
| 6052 | + | impl Advanced for FromAdvanced<'_> { | |
| 6053 | + | #[cfg(unix)] | |
| 6054 | + | fn mirror(&self) -> Option<Mirror> { | |
| 6055 | + | let mirror = &self.state.mirror; | |
| 6056 | + | Some(Mirror { | |
| 6057 | + | enabled: mirror.mirror_enabled, | |
| 6058 | + | shown: collapse_home(&mirror.mirror_path), | |
| 6059 | + | path: mirror.mirror_path.display().to_string(), | |
| 6060 | + | failed: mirror.last_error.clone(), | |
| 6061 | + | }) | |
| 6062 | + | } | |
| 6063 | + | ||
| 6064 | + | #[cfg(not(unix))] | |
| 6065 | + | fn mirror(&self) -> Option<Mirror> { | |
| 6066 | + | None | |
| 6067 | + | } | |
| 6068 | + | ||
| 6069 | + | fn import_theme(&self, path: &str) { | |
| 6070 | + | self.push(Intent::ImportTheme(std::path::PathBuf::from(path))); | |
| 6071 | + | } | |
| 6072 | + | ||
| 6073 | + | fn enable_mirror(&self, enabled: bool) { | |
| 6074 | + | self.push(Intent::EnableMirror(enabled)); | |
| 6075 | + | } | |
| 6076 | + | ||
| 6077 | + | fn set_mirror_path(&self, path: &str) { | |
| 6078 | + | self.push(Intent::MirrorPath(std::path::PathBuf::from(path))); | |
| 6079 | + | } | |
| 6080 | + | } | |
| 6081 | + | ||
| 6082 | + | impl FromAdvanced<'_> { | |
| 6083 | + | /// Record what the described screen asked for. | |
| 6084 | + | fn push(&self, intent: Intent) { | |
| 6085 | + | self.intents.borrow_mut().push(intent); | |
| 6086 | + | } | |
| 6087 | + | } | |
| 6088 | + | ||
| 5988 | 6089 | /// The library-wide tag queue, as much of it as a described screen needs. | |
| 5989 | 6090 | /// | |
| 5990 | 6091 | /// One struct where the flow is an enum, and [`Forging`]'s reason again: the | |
| @@ -7010,6 +7111,8 @@ | |||
| 7010 | 7111 | pub classifier: &'a dyn Classifier, | |
| 7011 | 7112 | /// The licence, for the settings window's License section. | |
| 7012 | 7113 | pub licence: &'a dyn Licence, | |
| 7114 | + | /// The rest of Advanced, for the settings window's last section. | |
| 7115 | + | pub advanced: &'a dyn Advanced, | |
| 7013 | 7116 | /// The themes on offer, resolved by the host at startup. | |
| 7014 | 7117 | pub themes: &'a [ThemeChoice], | |
| 7015 | 7118 | } | |
| @@ -7024,8 +7127,8 @@ | |||
| 7024 | 7127 | integrity::routes(importing::routes(naming::routes(toolbar::routes( | |
| 7025 | 7128 | library::routes(shell::routes(help::routes(bulk::routes(detail::routes( | |
| 7026 | 7129 | export::routes(files::routes(sync::routes(classifier::routes( | |
| 7027 | - | licence::routes(trash::routes(storage::routes(settings::routes( | |
| 7028 | - | Router::new(), | |
| 7130 | + | advanced::routes(licence::routes(trash::routes(storage::routes( | |
| 7131 | + | settings::routes(Router::new()), | |
| 7029 | 7132 | )))), | |
| 7030 | 7133 | )))), | |
| 7031 | 7134 | ))))), |
| @@ -33,10 +33,10 @@ | |||
| 33 | 33 | use std::cell::RefCell; | |
| 34 | 34 | ||
| 35 | 35 | use super::{ | |
| 36 | - | FromBackend, FromBar, FromBulk, FromClassifier, FromContents, FromEditor, FromExport, | |
| 37 | - | FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromLicence, FromNaming, | |
| 38 | - | FromQueue, FromSelection, FromStorage, FromSyncManager, FromTrash, FromWindow, Intent, Panels, | |
| 39 | - | Setting, Sync, ThemeChoice, Unconfigured, | |
| 36 | + | FromAdvanced, FromBackend, FromBar, FromBulk, FromClassifier, FromContents, FromEditor, | |
| 37 | + | FromExport, FromFilters, FromForge, FromImport, FromIntegrity, FromLibrary, FromLicence, | |
| 38 | + | FromNaming, FromQueue, FromSelection, FromStorage, FromSyncManager, FromTrash, FromWindow, | |
| 39 | + | Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured, | |
| 40 | 40 | }; | |
| 41 | 41 | use crate::state::BrowserState; | |
| 42 | 42 | use crate::ui::theme; | |
| @@ -1708,6 +1708,19 @@ | |||
| 1708 | 1708 | ctx.copy_text(machine); | |
| 1709 | 1709 | } | |
| 1710 | 1710 | } | |
| 1711 | + | // `makeover::import_theme` validates, names and copies in one call: | |
| 1712 | + | // it refuses a file with no color section and an id that would | |
| 1713 | + | // escape the directory, neither of which a hand-rolled copy checked. | |
| 1714 | + | Intent::ImportTheme(path) => import_theme(state, &path), | |
| 1715 | + | Intent::EnableMirror(enabled) => { | |
| 1716 | + | state.mirror.last_error = state | |
| 1717 | + | .set_mirror_enabled(enabled) | |
| 1718 | + | .err() | |
| 1719 | + | .map(|error| error.to_string()); | |
| 1720 | + | } | |
| 1721 | + | Intent::MirrorPath(path) => { | |
| 1722 | + | state.mirror.last_error = state.set_mirror_path(path).err().map(|e| e.to_string()); | |
| 1723 | + | } | |
| 1711 | 1724 | Intent::Deactivate => { | |
| 1712 | 1725 | state.settings.pending_action = Some(crate::state::VaultAction::DeactivateLicense); | |
| 1713 | 1726 | } | |
| @@ -1744,6 +1757,31 @@ | |||
| 1744 | 1757 | state.described.settings = None; | |
| 1745 | 1758 | } | |
| 1746 | 1759 | ||
| 1760 | + | /// Add a theme file to the custom themes, and show it. | |
| 1761 | + | /// | |
| 1762 | + | /// Lifted from the deleted `ui/settings_panel.rs`. Importing a theme selects it, | |
| 1763 | + | /// which is the shipped behaviour and is what makes the act legible: the reader | |
| 1764 | + | /// sees the thing they just imported rather than a line saying it worked. | |
| 1765 | + | fn import_theme(state: &mut BrowserState, path: &std::path::Path) { | |
| 1766 | + | let Some(custom) = theme::custom_themes_dir() else { | |
| 1767 | + | "Theme import failed: no custom themes directory available.".clone_into(&mut state.status); | |
| 1768 | + | return; | |
| 1769 | + | }; | |
| 1770 | + | match makeover::import_theme(path, &custom) { | |
| 1771 | + | Ok(meta) => { | |
| 1772 | + | let chosen = theme::ThemeSelection::Fixed(meta.id.clone()); | |
| 1773 | + | theme::set_selection(chosen.clone()); | |
| 1774 | + | state.theme_selection = chosen; | |
| 1775 | + | state.save_theme_preference(); | |
| 1776 | + | state.status = format!("Imported theme: {}", meta.id); | |
| 1777 | + | } | |
| 1778 | + | Err(error) => { | |
| 1779 | + | tracing::error!("Failed to import theme: {error}"); | |
| 1780 | + | state.status = format!("Theme import failed: {error}"); | |
| 1781 | + | } | |
| 1782 | + | } | |
| 1783 | + | } | |
| 1784 | + | ||
| 1747 | 1785 | /// Set one part of one condition of the rule being authored. | |
| 1748 | 1786 | /// | |
| 1749 | 1787 | /// The coercion is here rather than in the route because it is the app's rule: | |
| @@ -2609,6 +2647,7 @@ | |||
| 2609 | 2647 | let trash = FromTrash { state, intents }; | |
| 2610 | 2648 | let classifier = FromClassifier { state, intents }; | |
| 2611 | 2649 | let licence = FromLicence { state, intents }; | |
| 2650 | + | let advanced = FromAdvanced { state, intents }; | |
| 2612 | 2651 | let panels = Panels { | |
| 2613 | 2652 | config: &config, | |
| 2614 | 2653 | sync, | |
| @@ -2630,6 +2669,7 @@ | |||
| 2630 | 2669 | trash: &trash, | |
| 2631 | 2670 | classifier: &classifier, | |
| 2632 | 2671 | licence: &licence, | |
| 2672 | + | advanced: &advanced, | |
| 2633 | 2673 | themes, | |
| 2634 | 2674 | }; | |
| 2635 | 2675 | super::router() |
| @@ -4,76 +4,73 @@ | |||
| 4 | 4 | //! only settings screen there is: `ui/settings_panel.rs` is deleted and this is | |
| 5 | 5 | //! what opening Settings opens. | |
| 6 | 6 | //! | |
| 7 | - | //! # What the flip left out, and where it went | |
| 7 | + | //! # What the flip left out, and where all five of them went | |
| 8 | 8 | //! | |
| 9 | - | //! **The window is five sections shorter than the one it replaced.** Storage, | |
| 9 | + | //! **The window was five sections shorter than the one it replaced.** Storage, | |
| 10 | 10 | //! Trash, License, the classifier and Import Theme were drawn by the deleted | |
| 11 | - | //! file, are not described here, and are therefore missing from the app until | |
| 12 | - | //! each is. That is `da48cb6d` applied, ruled by Max on 2026-08-22 for the same | |
| 13 | - | //! situation in goingson: the sections go away at the flip and come back as | |
| 14 | - | //! each is described, and the interval is survivable because no build ships in | |
| 15 | - | //! it. Each has its own task; none of them is abandoned here. | |
| 11 | + | //! file and went with it, which is `da48cb6d` applied: the sections go away at | |
| 12 | + | //! the flip and come back as each is described, and the interval is survivable | |
| 13 | + | //! because no build ships in it. | |
| 16 | 14 | //! | |
| 17 | - | //! Read the table below as what was *already* describable when the flip landed | |
| 18 | - | //! rather than as a verdict on the other five. Two of them -- Storage and Trash | |
| 19 | - | //! -- have had their stated reason weakened since it was written: `Locate` and | |
| 20 | - | //! `Outcome::File` between them say most of what Storage needed, and Trash is | |
| 21 | - | //! tombstone rows out of the app's own database rather than the filesystem the | |
| 22 | - | //! line below calls it. | |
| 15 | + | //! **All five are back as of 2026-08-25**, each in a module of its own, and the | |
| 16 | + | //! interval was one day. What they cost, together, is the finding worth | |
| 17 | + | //! keeping: **three of the five were held up by a refusal that had gone stale**, | |
| 18 | + | //! and none of the three was re-read until somebody was made to count. | |
| 19 | + | //! | |
| 20 | + | //! - *Storage* was "library paths, reachability, relocation: the filesystem". | |
| 21 | + | //! `Outcome::Locate` had shipped, and the reachability and the scan's numbers | |
| 22 | + | //! are host facts the app resolves before a frame runs. | |
| 23 | + | //! - *Trash* was "filesystem sizes and a destructive sweep over them". The rows | |
| 24 | + | //! are the app's own tombstone table, the size is a column on it, and the | |
| 25 | + | //! sweep runs at startup from somewhere else entirely. | |
| 26 | + | //! - *Import Theme* was "nothing carries the picked file's bytes to a route". | |
| 27 | + | //! It never wanted bytes: `makeover::import_theme` takes a path. | |
| 28 | + | //! | |
| 29 | + | //! The other two were not refusals at all. *License* was "a key exchanged with | |
| 30 | + | //! a server" and exchanges nothing; the *classifier* was never a settings | |
| 31 | + | //! section, and is `/classifier` now by Max's ruling. | |
| 32 | + | //! | |
| 33 | + | //! A refusal recorded against a vocabulary keeps its wording after the | |
| 34 | + | //! vocabulary moves, and the wording is what the next reader believes. Count | |
| 35 | + | //! before restating one. | |
| 23 | 36 | //! | |
| 24 | 37 | //! # What is describable here, and what is not | |
| 25 | 38 | //! | |
| 26 | - | //! The panel had nine sections and **four of them are described**. That ratio | |
| 27 | - | //! is not a disappointment; it is the same result goingson's settings port got | |
| 28 | - | //! (five of eight not describable) and for the same cause, which is worth | |
| 29 | - | //! stating precisely because it is easy to misread as a gap in the vocabulary: | |
| 39 | + | //! The panel had nine sections and **all nine are answered**: eight described, | |
| 40 | + | //! and the classifier moved out to an address of its own. Four went through at | |
| 41 | + | //! the flip and the rest followed over the next day. | |
| 30 | 42 | //! | |
| 31 | - | //! **A handler is `fn(&S, Request)`.** It is sync, it holds only what the app | |
| 32 | - | //! put in `S`, and it cannot open a window. So a section whose subject is the | |
| 33 | - | //! *host* rather than the app's own data does not come through: | |
| 43 | + | //! The constraint that shaped the original four is still the real one and is | |
| 44 | + | //! worth stating, because it is what every stale refusal above was reaching | |
| 45 | + | //! for: **a handler is `fn(&S, Request)`.** It is sync, it holds only what the | |
| 46 | + | //! app put in `S`, and it cannot open a window. What that rules out is a | |
| 47 | + | //! section whose subject is the *host*; what it does not rule out is a section | |
| 48 | + | //! about files, which is where three of the five went wrong. | |
| 34 | 49 | //! | |
| 35 | - | //! | Section | Described | Why not | | |
| 50 | + | //! | Section | Described | Where | | |
| 36 | 51 | //! |---|---|---| | |
| 37 | 52 | //! | Appearance | yes | a select over themes the host resolved at startup | | |
| 38 | 53 | //! | Preview | yes | two booleans in `user_config` | | |
| 39 | 54 | //! | Forge | yes | one boolean in `user_config` | | |
| 40 | 55 | //! | Display | yes | five booleans, a number and a control | | |
| 41 | 56 | //! | Storage | yes | as of the section's own port; see [`storage`](super::storage) | | |
| 42 | - | //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog | | |
| 57 | + | //! | Advanced | yes | export here, the rest in [`advanced`](super::advanced) | | |
| 43 | 58 | //! | License | yes | no key is exchanged here; see [`licence`](super::licence) | | |
| 44 | 59 | //! | Trash | yes | as of the section's own port; see [`trash`](super::trash) | | |
| 45 | 60 | //! | Classifier | yes | not a section at all; see [`classifier`](super::classifier) | | |
| 46 | 61 | //! | |
| 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 was the same shape of "no" and a weaker one: its rows are tombstones | |
| 51 | - | //! out of the app's own table, its sizes are a column, and its sweep runs at | |
| 52 | - | //! startup. It is described now, in [`trash`](super::trash). | |
| 62 | + | //! **Advanced was the interesting one, and both halves are answered now.** This | |
| 63 | + | //! port filed "a control that asks the host where to put something and then | |
| 64 | + | //! acts has no vocabulary" as the second consumer of goingson's finding. Max | |
| 65 | + | //! ruled it on 2026-08-21 (`67881a88`) and the answer was that the premise was | |
| 66 | + | //! wrong: there is no picker in it. `Outcome::File` hands back a name, a kind | |
| 67 | + | //! and the bytes, and **where they land is the host's** -- a save dialog here, | |
| 68 | + | //! the working directory on a terminal, a download in a browser. Export Current | |
| 69 | + | //! is described here and is this host's first consumer of the member; | |
| 70 | + | //! `panel::hand_over` is the host half. | |
| 53 | 71 | //! | |
| 54 | - | //! **Advanced was the interesting one, and half of it is answered.** This port | |
| 55 | - | //! filed "a control that asks the host where to put something and then acts has | |
| 56 | - | //! no vocabulary" as the second consumer of goingson's finding. Max ruled it on | |
| 57 | - | //! 2026-08-21 (`67881a88`) and the answer was that the premise was wrong: there | |
| 58 | - | //! is no picker in it. `Outcome::File` hands back a name, a kind and the bytes, | |
| 59 | - | //! and **where they land is the host's** -- a save dialog here, the working | |
| 60 | - | //! directory on a terminal, a download in a browser. Export Current is described | |
| 61 | - | //! now and is this host's first consumer of the member; `panel::hand_over` is | |
| 62 | - | //! the host half. | |
| 63 | - | //! | |
| 64 | - | //! Import Theme stayed out on a different reading, and **the reading was wrong** | |
| 65 | - | //! (measured 2026-08-25). It said `FieldKind::File` names what may be picked and | |
| 66 | - | //! nothing carries the picked file's *bytes* to a route on this host. Import | |
| 67 | - | //! Theme does not want bytes: it is | |
| 68 | - | //! `makeover::import_theme(&path, &custom_dir)`, which takes a **path** and does | |
| 69 | - | //! its own reading. So does every other picker left in this app. | |
| 70 | - | //! | |
| 71 | - | //! `Outcome::Locate` answers exactly that — `Sought::File { accept }` asks for a | |
| 72 | - | //! file, the host runs the picker, and a handle comes back — and `panel::locate` | |
| 73 | - | //! has served all three shapes since quasi 0.60. The paragraph was written | |
| 74 | - | //! before that shipped and describes a gap with no consumer. Import Theme is | |
| 75 | - | //! ordinary work: the same `locate`-then-act pair the Storage section's | |
| 76 | - | //! relocation already uses. | |
| 72 | + | //! Import Theme and the library mirror are [`advanced`](super::advanced), which | |
| 73 | + | //! is why this module opens the section and hands the rest over. | |
| 77 | 74 | //! | |
| 78 | 75 | //! # The finding this port adds | |
| 79 | 76 | //! | |
| @@ -229,12 +226,12 @@ | |||
| 229 | 226 | Action::get("/classifier"), | |
| 230 | 227 | ))); | |
| 231 | 228 | ||
| 232 | - | // Advanced, half of it. See the header: Export Current is describable as of | |
| 233 | - | // quasi 0.50.0 and Import Theme is not, so the section is what the | |
| 234 | - | // vocabulary can say rather than all-or-nothing. | |
| 229 | + | // Advanced, all of it as of 2026-08-25. The heading is unconditional and | |
| 230 | + | // the export is not: a theme that is gone has nothing to export, and the | |
| 231 | + | // section still has an import and a mirror under it. | |
| 232 | + | body = body.with(Node::section("Advanced")); | |
| 235 | 233 | if let Some(active) = active_theme(state) { | |
| 236 | 234 | body = body | |
| 237 | - | .with(Node::section("Advanced")) | |
| 238 | 235 | .with(Node::text(format!( | |
| 239 | 236 | "The theme showing is {}. Exporting writes {}.toml wherever you choose.", | |
| 240 | 237 | active.name, active.id | |
| @@ -244,6 +241,7 @@ | |||
| 244 | 241 | Action::post("/settings/theme/export"), | |
| 245 | 242 | ))); | |
| 246 | 243 | } | |
| 244 | + | body = super::advanced::section(body, state); | |
| 247 | 245 | ||
| 248 | 246 | Ok(Screen::sidebar_content("Settings").with(body)) | |
| 249 | 247 | } |
| @@ -12,12 +12,12 @@ | |||
| 12 | 12 | use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen}; | |
| 13 | 13 | ||
| 14 | 14 | use super::{ | |
| 15 | - | Analysed, Analysis, Authoring, Bar, Bulk, Candidate, Channels, Chop, Chosen, Classifier, | |
| 16 | - | Collection, ColumnsShown, Config, Coverage, Crumb, Decision, Deleted, Detail, Detailed, | |
| 17 | - | DeviceChoice, Doing, Draft, Editing, Export, Failure, Files, Filter, Filters, Focus, Folder, | |
| 18 | - | FolderTags, Forge, Forging, Format, Group, Halted, Harvested, Head, Holding, Importing, | |
| 15 | + | Advanced, Analysed, Analysis, Authoring, Bar, Bulk, Candidate, Channels, Chop, Chosen, | |
| 16 | + | Classifier, Collection, ColumnsShown, Config, Coverage, Crumb, Decision, Deleted, Detail, | |
| 17 | + | Detailed, DeviceChoice, Doing, Draft, Editing, Export, Failure, Files, Filter, Filters, Focus, | |
| 18 | + | Folder, FolderTags, Forge, Forging, Format, Group, Halted, Harvested, Head, Holding, Importing, | |
| 19 | 19 | Integrity, Keys, Knob, Layer, Library, LibraryEntry, Licence, Measure, Measures, Migrating, | |
| 20 | - | Naming, Narrowing, Order, Panel, Panels, Part, Phase, Pile, Playing, Policy, Preflight, | |
| 20 | + | Mirror, Naming, Narrowing, Order, Panel, Panels, Part, Phase, Pile, Playing, Policy, Preflight, | |
| 21 | 21 | Pricing, ProfileChoice, Queue, Queued, Readiness, Reviewed, Ruling, Sample, Saying, Scan, | |
| 22 | 22 | Scope, Searching, Setting, Settings, Shareable, Shared, Sharing, Shell, Source, Spread, Stage, | |
| 23 | 23 | State, Status, Storage, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, | |
| @@ -270,6 +270,7 @@ | |||
| 270 | 270 | trash: &Emptied, | |
| 271 | 271 | classifier: &Untrained, | |
| 272 | 272 | licence: &Unlicensed, | |
| 273 | + | advanced: &Plain, | |
| 273 | 274 | themes: &themes, | |
| 274 | 275 | }; | |
| 275 | 276 | router().handle(&state, request) | |
| @@ -439,6 +440,7 @@ | |||
| 439 | 440 | trash: &Emptied, | |
| 440 | 441 | classifier: &Untrained, | |
| 441 | 442 | licence: &Unlicensed, | |
| 443 | + | advanced: &Plain, | |
| 442 | 444 | themes: &themes, | |
| 443 | 445 | }; | |
| 444 | 446 | router().handle(&state, request) | |
| @@ -504,6 +506,7 @@ | |||
| 504 | 506 | trash: &Emptied, | |
| 505 | 507 | classifier: &Untrained, | |
| 506 | 508 | licence: &Unlicensed, | |
| 509 | + | advanced: &Plain, | |
| 507 | 510 | themes: &themes, | |
| 508 | 511 | }; | |
| 509 | 512 | router().handle(&state, request) | |
| @@ -672,6 +675,7 @@ | |||
| 672 | 675 | trash: &Emptied, | |
| 673 | 676 | classifier: &Untrained, | |
| 674 | 677 | licence: &Unlicensed, | |
| 678 | + | advanced: &Plain, | |
| 675 | 679 | themes: &themes, | |
| 676 | 680 | }; | |
| 677 | 681 | let response = router() | |
| @@ -711,7 +715,12 @@ | |||
| 711 | 715 | // open a library, restore a deleted sample, count what is on disk -- and | |
| 712 | 716 | // none is a key with a value. Only Storage's three-question form writes the | |
| 713 | 717 | // way this screen does, and it does so through one route. | |
| 714 | - | const SECTIONS: [&str; 3] = ["/settings/storage", "/settings/trash", "/settings/licence"]; | |
| 718 | + | const SECTIONS: [&str; 4] = [ | |
| 719 | + | "/settings/storage", | |
| 720 | + | "/settings/trash", | |
| 721 | + | "/settings/licence", | |
| 722 | + | "/settings/advanced", | |
| 723 | + | ]; | |
| 715 | 724 | let counted = |prefix: &str| { | |
| 716 | 725 | table | |
| 717 | 726 | .iter() | |
| @@ -732,6 +741,11 @@ | |||
| 732 | 741 | // behind, and the one that shows what the rule above is worth: neither of | |
| 733 | 742 | // these is a key with a value either. | |
| 734 | 743 | assert_eq!(counted("/settings/licence"), 2, "{table:?}"); | |
| 744 | + | // Two `Locate` pairs and a toggle. `theme/export` stays outside this prefix | |
| 745 | + | // because it is the one control here that predates the section's own | |
| 746 | + | // module, and moving a shipped address to tidy a count is churn. | |
| 747 | + | assert_eq!(counted("/settings/advanced"), 5, "{table:?}"); | |
| 748 | + | assert!(table.contains(&(Method::Post, "/settings/theme/export".to_owned()))); | |
| 735 | 749 | } | |
| 736 | 750 | ||
| 737 | 751 | /// A router call against the settings screen, over a given config store. | |
| @@ -760,6 +774,7 @@ | |||
| 760 | 774 | trash: &Emptied, | |
| 761 | 775 | classifier: &Untrained, | |
| 762 | 776 | licence: &Unlicensed, | |
| 777 | + | advanced: &Plain, | |
| 763 | 778 | themes: &themes, | |
| 764 | 779 | }; | |
| 765 | 780 | router().handle(&state, request) | |
| @@ -835,6 +850,7 @@ | |||
| 835 | 850 | trash: &Emptied, | |
| 836 | 851 | classifier: &Untrained, | |
| 837 | 852 | licence: &Unlicensed, | |
| 853 | + | advanced: &Plain, | |
| 838 | 854 | themes: &themes, | |
| 839 | 855 | }; | |
| 840 | 856 | ||
| @@ -890,6 +906,7 @@ | |||
| 890 | 906 | trash: &Emptied, | |
| 891 | 907 | classifier: &Untrained, | |
| 892 | 908 | licence: &Unlicensed, | |
| 909 | + | advanced: &Plain, | |
| 893 | 910 | themes: &themes, | |
| 894 | 911 | }; | |
| 895 | 912 | let refused = router().handle( | |
| @@ -931,6 +948,7 @@ | |||
| 931 | 948 | trash: &Emptied, | |
| 932 | 949 | classifier: &Untrained, | |
| 933 | 950 | licence: &Unlicensed, | |
| 951 | + | advanced: &Plain, | |
| 934 | 952 | themes: &themes, | |
| 935 | 953 | }; | |
| 936 | 954 | ||
| @@ -995,6 +1013,7 @@ | |||
| 995 | 1013 | trash: &Emptied, | |
| 996 | 1014 | classifier: &Untrained, | |
| 997 | 1015 | licence: &Unlicensed, | |
| 1016 | + | advanced: &Plain, | |
| 998 | 1017 | themes: &themes, | |
| 999 | 1018 | }; | |
| 1000 | 1019 | let response = router() | |
| @@ -1055,6 +1074,7 @@ | |||
| 1055 | 1074 | trash: &Emptied, | |
| 1056 | 1075 | classifier: &Untrained, | |
| 1057 | 1076 | licence: &Unlicensed, | |
| 1077 | + | advanced: &Plain, | |
| 1058 | 1078 | themes: &themes, | |
| 1059 | 1079 | }; | |
| 1060 | 1080 | let response = router() | |
| @@ -1228,6 +1248,7 @@ | |||
| 1228 | 1248 | trash: &Emptied, | |
| 1229 | 1249 | classifier: &Untrained, | |
| 1230 | 1250 | licence: &Unlicensed, | |
| 1251 | + | advanced: &Plain, | |
| 1231 | 1252 | themes: &themes, | |
| 1232 | 1253 | }; | |
| 1233 | 1254 | router().handle(&state, request) | |
| @@ -2857,6 +2878,7 @@ | |||
| 2857 | 2878 | trash: &Emptied, | |
| 2858 | 2879 | classifier: &Untrained, | |
| 2859 | 2880 | licence: &Unlicensed, | |
| 2881 | + | advanced: &Plain, | |
| 2860 | 2882 | themes: &themes, | |
| 2861 | 2883 | }; | |
| 2862 | 2884 | router().handle(&state, request) | |
| @@ -3415,6 +3437,7 @@ | |||
| 3415 | 3437 | trash: &Emptied, | |
| 3416 | 3438 | classifier: &Untrained, | |
| 3417 | 3439 | licence: &Unlicensed, | |
| 3440 | + | advanced: &Plain, | |
| 3418 | 3441 | themes: &themes, | |
| 3419 | 3442 | }; | |
| 3420 | 3443 | router().handle(&state, request) | |
| @@ -3808,6 +3831,7 @@ | |||
| 3808 | 3831 | trash: &Emptied, | |
| 3809 | 3832 | classifier: &Untrained, | |
| 3810 | 3833 | licence: &Unlicensed, | |
| 3834 | + | advanced: &Plain, | |
| 3811 | 3835 | themes: &themes, | |
| 3812 | 3836 | }; | |
| 3813 | 3837 | router().handle(&state, request) | |
| @@ -4188,6 +4212,7 @@ | |||
| 4188 | 4212 | trash: &Emptied, | |
| 4189 | 4213 | classifier: &Untrained, | |
| 4190 | 4214 | licence: &Unlicensed, | |
| 4215 | + | advanced: &Plain, | |
| 4191 | 4216 | themes: &themes, | |
| 4192 | 4217 | }; | |
| 4193 | 4218 | router().handle(&state, request) | |
| @@ -4613,6 +4638,7 @@ | |||
| 4613 | 4638 | trash: &Emptied, | |
| 4614 | 4639 | classifier: &Untrained, | |
| 4615 | 4640 | licence: &Unlicensed, | |
| 4641 | + | advanced: &Plain, | |
| 4616 | 4642 | themes: &themes, | |
| 4617 | 4643 | }; | |
| 4618 | 4644 | router().handle(&state, request) | |
| @@ -5104,6 +5130,7 @@ | |||
| 5104 | 5130 | trash: &Emptied, | |
| 5105 | 5131 | classifier: &Untrained, | |
| 5106 | 5132 | licence: &Unlicensed, | |
| 5133 | + | advanced: &Plain, | |
| 5107 | 5134 | themes: &themes, | |
| 5108 | 5135 | }; | |
| 5109 | 5136 | router().handle(&state, request) | |
| @@ -5984,6 +6011,28 @@ | |||
| 5984 | 6011 | fn deactivate(&self) {} | |
| 5985 | 6012 | } | |
| 5986 | 6013 | ||
| 6014 | + | /// A mirror that is off, at a path nobody has changed. | |
| 6015 | + | /// | |
| 6016 | + | /// The quiet fixture, matching [`OneLibrary`] and [`Unlicensed`]. The section's | |
| 6017 | + | /// own tests use [`FakeAdvanced`], which records and can answer `None` for the | |
| 6018 | + | /// mirror the way a Windows host does. | |
| 6019 | + | struct Plain; | |
| 6020 | + | ||
| 6021 | + | impl Advanced for Plain { | |
| 6022 | + | fn mirror(&self) -> Option<Mirror> { | |
| 6023 | + | Some(Mirror { | |
| 6024 | + | enabled: false, | |
| 6025 | + | shown: "~/Music/audiofiles-mirror".to_owned(), | |
| 6026 | + | path: "/home/max/Music/audiofiles-mirror".to_owned(), | |
| 6027 | + | failed: None, | |
| 6028 | + | }) | |
| 6029 | + | } | |
| 6030 | + | ||
| 6031 | + | fn import_theme(&self, _path: &str) {} | |
| 6032 | + | fn enable_mirror(&self, _enabled: bool) {} | |
| 6033 | + | fn set_mirror_path(&self, _path: &str) {} | |
| 6034 | + | } | |
| 6035 | + | ||
| 5987 | 6036 | /// A namer in memory, recording what was asked of it and refusing on demand. | |
| 5988 | 6037 | #[derive(Default)] | |
| 5989 | 6038 | struct FakeNaming { | |
| @@ -6109,6 +6158,7 @@ | |||
| 6109 | 6158 | trash: &Emptied, | |
| 6110 | 6159 | classifier: &Untrained, | |
| 6111 | 6160 | licence: &Unlicensed, | |
| 6161 | + | advanced: &Plain, | |
| 6112 | 6162 | themes: &themes, | |
| 6113 | 6163 | }; | |
| 6114 | 6164 | router().handle(&state, request) | |
| @@ -6275,6 +6325,7 @@ | |||
| 6275 | 6325 | trash: &Emptied, | |
| 6276 | 6326 | classifier: &Untrained, | |
| 6277 | 6327 | licence: &Unlicensed, | |
| 6328 | + | advanced: &Plain, | |
| 6278 | 6329 | themes: &themes, | |
| 6279 | 6330 | }; | |
| 6280 | 6331 | router().handle(&state, request) | |
| @@ -6585,6 +6636,7 @@ | |||
| 6585 | 6636 | trash: &Emptied, | |
| 6586 | 6637 | classifier: &Untrained, | |
| 6587 | 6638 | licence: &Unlicensed, | |
| 6639 | + | advanced: &Plain, | |
| 6588 | 6640 | themes: &themes, | |
| 6589 | 6641 | }; | |
| 6590 | 6642 | router().handle(&state, request) | |
| @@ -6672,6 +6724,7 @@ | |||
| 6672 | 6724 | trash: &Emptied, | |
| 6673 | 6725 | classifier: &Untrained, | |
| 6674 | 6726 | licence: &Unlicensed, | |
| 6727 | + | advanced: &Plain, | |
| 6675 | 6728 | themes: &themes, | |
| 6676 | 6729 | }; | |
| 6677 | 6730 | let response = router().handle(&state, Request::get("/")).unwrap(); | |
| @@ -6857,6 +6910,7 @@ | |||
| 6857 | 6910 | trash: &Emptied, | |
| 6858 | 6911 | classifier: &Untrained, | |
| 6859 | 6912 | licence: &Unlicensed, | |
| 6913 | + | advanced: &Plain, | |
| 6860 | 6914 | themes: &themes, | |
| 6861 | 6915 | }; | |
| 6862 | 6916 | router().handle(&state, request) | |
| @@ -8438,6 +8492,7 @@ | |||
| 8438 | 8492 | trash: &Emptied, | |
| 8439 | 8493 | classifier: &Untrained, | |
| 8440 | 8494 | licence: &Unlicensed, | |
| 8495 | + | advanced: &Plain, | |
| 8441 | 8496 | themes: &themes, | |
| 8442 | 8497 | }; | |
| 8443 | 8498 | router().handle(&state, request) | |
| @@ -9022,6 +9077,7 @@ | |||
| 9022 | 9077 | trash: &Emptied, | |
| 9023 | 9078 | classifier: &Untrained, | |
| 9024 | 9079 | licence: &Unlicensed, | |
| 9080 | + | advanced: &Plain, | |
| 9025 | 9081 | themes: &themes, | |
| 9026 | 9082 | }; | |
| 9027 | 9083 | router().handle(&state, request) | |
| @@ -9164,6 +9220,7 @@ | |||
| 9164 | 9220 | trash: &Emptied, | |
| 9165 | 9221 | classifier: &Untrained, | |
| 9166 | 9222 | licence: &Unlicensed, | |
| 9223 | + | advanced: &Plain, | |
| 9167 | 9224 | themes: &themes, | |
| 9168 | 9225 | }; | |
| 9169 | 9226 | router().handle(&state, request) | |
| @@ -9859,6 +9916,7 @@ | |||
| 9859 | 9916 | trash: &Emptied, | |
| 9860 | 9917 | classifier: &Untrained, | |
| 9861 | 9918 | licence: &Unlicensed, | |
| 9919 | + | advanced: &Plain, | |
| 9862 | 9920 | themes: &themes, | |
| 9863 | 9921 | }; | |
| 9864 | 9922 | router().handle(&state, request) | |
| @@ -10284,6 +10342,7 @@ | |||
| 10284 | 10342 | trash, | |
| 10285 | 10343 | classifier: &Untrained, | |
| 10286 | 10344 | licence: &Unlicensed, | |
| 10345 | + | advanced: &Plain, | |
| 10287 | 10346 | themes: &themes, | |
| 10288 | 10347 | }; | |
| 10289 | 10348 | router().handle(&state, request) | |
| @@ -10762,6 +10821,7 @@ | |||
| 10762 | 10821 | trash: &Emptied, | |
| 10763 | 10822 | classifier, | |
| 10764 | 10823 | licence: &Unlicensed, | |
| 10824 | + | advanced: &Plain, | |
| 10765 | 10825 | themes: &themes, | |
| 10766 | 10826 | }; | |
| 10767 | 10827 | router().handle(&state, request) | |
| @@ -11424,6 +11484,7 @@ | |||
| 11424 | 11484 | trash: &Emptied, | |
| 11425 | 11485 | classifier: &Untrained, | |
| 11426 | 11486 | licence, | |
| 11487 | + | advanced: &Plain, | |
| 11427 | 11488 | themes: &themes, | |
| 11428 | 11489 | }; | |
| 11429 | 11490 | router().handle(&state, request) | |
| @@ -11510,3 +11571,249 @@ | |||
| 11510 | 11571 | // The key half of the section is unaffected: the two are independent reads. | |
| 11511 | 11572 | assert!(said_deep(&screen).contains("audiofiles Pro")); | |
| 11512 | 11573 | } | |
| 11574 | + | ||
| 11575 | + | /// The advanced settings in memory, recording what was asked of them. | |
| 11576 | + | #[derive(Default)] | |
| 11577 | + | struct FakeAdvanced { | |
| 11578 | + | mirror: Option<Mirror>, | |
| 11579 | + | asked: RefCell<Vec<String>>, | |
| 11580 | + | } | |
| 11581 | + | ||
| 11582 | + | impl FakeAdvanced { | |
| 11583 | + | /// A host with a mirror, switched off. | |
| 11584 | + | fn unix() -> Self { | |
| 11585 | + | Self { | |
| 11586 | + | mirror: Some(Mirror { | |
| 11587 | + | enabled: false, | |
| 11588 | + | shown: "~/Music/audiofiles-mirror".to_owned(), | |
| 11589 | + | path: "/home/max/Music/audiofiles-mirror".to_owned(), | |
| 11590 | + | failed: None, | |
| 11591 | + | }), | |
| 11592 | + | asked: RefCell::default(), | |
| 11593 | + | } | |
| 11594 | + | } | |
| 11595 | + | ||
| 11596 | + | /// A host with none, which is what Windows answers. | |
| 11597 | + | fn windows() -> Self { | |
| 11598 | + | Self::default() | |
| 11599 | + | } | |
| 11600 | + | ||
| 11601 | + | fn asked(&self) -> Vec<String> { | |
| 11602 | + | self.asked.borrow().clone() | |
| 11603 | + | } | |
| 11604 | + | } | |
| 11605 | + | ||
| 11606 | + | impl Advanced for FakeAdvanced { | |
| 11607 | + | fn mirror(&self) -> Option<Mirror> { | |
| 11608 | + | self.mirror.clone() | |
| 11609 | + | } | |
| 11610 | + | ||
| 11611 | + | fn import_theme(&self, path: &str) { | |
| 11612 | + | self.asked.borrow_mut().push(format!("import {path}")); | |
| 11613 | + | } | |
| 11614 | + | ||
| 11615 | + | fn enable_mirror(&self, enabled: bool) { | |
| 11616 | + | self.asked.borrow_mut().push(format!("enable {enabled}")); | |
| 11617 | + | } | |
| 11618 | + | ||
| 11619 | + | fn set_mirror_path(&self, path: &str) { | |
| 11620 | + | self.asked.borrow_mut().push(format!("path {path}")); | |
| 11621 | + | } | |
| 11622 | + | } | |
| 11623 | + | ||
| 11624 | + | /// A router call against these advanced settings. | |
| 11625 | + | fn advancing( | |
| 11626 | + | advanced: &FakeAdvanced, | |
| 11627 | + | request: Request, | |
| 11628 | + | ) -> Result<Response, quasi_router::RouteError> { | |
| 11629 | + | let store = Store::default(); | |
| 11630 | + | let sync = Offline; | |
| 11631 | + | let files = FakeFiles::default(); | |
| 11632 | + | let themes = themes(); | |
| 11633 | + | let state = Panels { | |
| 11634 | + | detail: &Unfocused, | |
| 11635 | + | bulk: &Unchosen, | |
| 11636 | + | shell: &Quiet, | |
| 11637 | + | library: &Empty, | |
| 11638 | + | bar: &Still, | |
| 11639 | + | config: &store, | |
| 11640 | + | sync: &sync, | |
| 11641 | + | files: &files, | |
| 11642 | + | export: &Idle, | |
| 11643 | + | naming: &Unnamed, | |
| 11644 | + | importing: &NoImport, | |
| 11645 | + | integrity: &Sound, | |
| 11646 | + | editor: &Unedited, | |
| 11647 | + | forge: &Unforged, | |
| 11648 | + | queue: &Unqueued, | |
| 11649 | + | filters: &Unfiltered, | |
| 11650 | + | storage: &OneLibrary, | |
| 11651 | + | trash: &Emptied, | |
| 11652 | + | classifier: &Untrained, | |
| 11653 | + | licence: &Unlicensed, | |
| 11654 | + | advanced, | |
| 11655 | + | themes: &themes, | |
| 11656 | + | }; | |
| 11657 | + | router().handle(&state, request) | |
| 11658 | + | } | |
| 11659 | + | ||
| 11660 | + | /// The settings screen these advanced settings produce. | |
| 11661 | + | fn advanced_screen(advanced: &FakeAdvanced) -> Screen { | |
| 11662 | + | screen_of(&advancing(advanced, Request::get("/settings")).expect("answered")).clone() | |
| 11663 | + | } | |
| 11664 | + | ||
| 11665 | + | #[test] | |
| 11666 | + | fn importing_a_theme_asks_the_host_for_a_toml_and_does_the_import_on_the_answer() { | |
| 11667 | + | // The act shape of `Outcome::Locate`: picking the file *is* the import, so | |
| 11668 | + | // the call that lands does the importing rather than stashing a path. | |
| 11669 | + | let advanced = FakeAdvanced::unix(); | |
| 11670 | + | let response = | |
| 11671 | + | advancing(&advanced, Request::post("/settings/advanced/theme/import")).expect("answered"); | |
| 11672 | + | let Outcome::Locate(asking) = &response.outcome else { | |
| 11673 | + | panic!("expected an ask, got {:?}", response.outcome); | |
| 11674 | + | }; | |
| 11675 | + | assert_eq!( | |
| 11676 | + | asking.answers.route(), | |
| 11677 | + | Some("/settings/advanced/theme/imported") | |
| 11678 | + | ); | |
| 11679 | + | let quasi_router::Sought::File { accept } = &asking.sought else { | |
| 11680 | + | panic!("wanted a file, asked for {:?}", asking.sought); | |
| 11681 | + | }; | |
| 11682 | + | assert_eq!(accept.len(), 1, "the picker was not narrowed to themes"); | |
| 11683 | + | assert!(advanced.asked().is_empty(), "the ask imported something"); | |
| 11684 | + | ||
| 11685 | + | let answered = Request::post("/settings/advanced/theme/imported") | |
| 11686 | + | .sending(Params::new().with("file".to_owned(), "/tmp/dusk.toml".to_owned())); | |
| 11687 | + | advancing(&advanced, answered).expect("answered"); | |
| 11688 | + | assert_eq!(advanced.asked(), ["import /tmp/dusk.toml"]); | |
| 11689 | + | } | |
| 11690 | + | ||
| 11691 | + | #[test] | |
| 11692 | + | fn backing_out_of_the_theme_picker_imports_nothing() { | |
| 11693 | + | let advanced = FakeAdvanced::unix(); | |
| 11694 | + | advancing( | |
| 11695 | + | &advanced, | |
| 11696 | + | Request::post("/settings/advanced/theme/imported"), | |
| 11697 | + | ) | |
| 11698 | + | .expect("answered"); | |
| 11699 | + | assert!(advanced.asked().is_empty()); | |
| 11700 | + | } | |
| 11701 | + | ||
| 11702 | + | #[test] | |
| 11703 | + | fn the_mirror_says_where_it_will_be_built_before_it_is_switched_on() { | |
| 11704 | + | // The shipped section's own reasoning: the reader should know where the | |
| 11705 | + | // tree lands without hunting for a hidden config. | |
| 11706 | + | let advanced = FakeAdvanced::unix(); | |
| 11707 | + | let said = said_deep(&advanced_screen(&advanced)); | |
| 11708 | + | assert!( | |
| 11709 | + | said.contains("Mirror location: ~/Music/audiofiles-mirror"), | |
| 11710 | + | "{said}" | |
| 11711 | + | ); | |
| 11712 | + | let enabled = deep_fields(&advanced_screen(&advanced)) | |
| 11713 | + | .into_iter() | |
| 11714 | + | .find(|field| field.name == "enabled") | |
| 11715 | + | .expect("the mirror toggle"); | |
| 11716 | + | assert_eq!(enabled.value.as_deref(), Some("")); | |
| 11717 | + | } | |
| 11718 | + | ||
| 11719 | + | #[test] | |
| 11720 | + | fn a_host_with_no_mirror_describes_none_and_refuses_all_three_addresses() { | |
| 11721 | + | // A description cannot carry a `cfg`, so the host answers `None` and the | |
| 11722 | + | // section is simply not there. The addresses still exist on every host, | |
| 11723 | + | // because a route table is not per-platform, so each refuses. | |
| 11724 | + | let advanced = FakeAdvanced::windows(); | |
| 11725 | + | let screen = advanced_screen(&advanced); | |
| 11726 | + | assert!(!said_deep(&screen).contains("Library Mirror")); | |
| 11727 | + | assert!(!acts(&screen).iter().any(|label| label == "Change...")); | |
| 11728 | + | ||
| 11729 | + | for address in [ | |
| 11730 | + | "/settings/advanced/mirror/enabled", | |
| 11731 | + | "/settings/advanced/mirror/folder", | |
| 11732 | + | "/settings/advanced/mirror/path", | |
| 11733 | + | ] { | |
| 11734 | + | assert!( | |
| 11735 | + | advancing(&advanced, Request::post(address)).is_err(), | |
| 11736 | + | "{address} answered on a host with no mirror" | |
| 11737 | + | ); | |
| 11738 | + | } | |
| 11739 | + | // Importing a theme is not platform-bound and still works. | |
| 11740 | + | assert!(advancing(&advanced, Request::post("/settings/advanced/theme/import")).is_ok()); | |
| 11741 | + | } | |
| 11742 | + | ||
| 11743 | + | #[test] | |
| 11744 | + | fn the_mirror_toggle_reads_an_unticked_box_as_off() { | |
| 11745 | + | let advanced = FakeAdvanced::unix(); | |
| 11746 | + | let on = Request::post("/settings/advanced/mirror/enabled") | |
| 11747 | + | .sending(Params::new().with("enabled".to_owned(), "on".to_owned())); | |
| 11748 | + | advancing(&advanced, on).expect("answered"); | |
| 11749 | + | advancing( | |
| 11750 | + | &advanced, | |
| 11751 | + | Request::post("/settings/advanced/mirror/enabled"), | |
| 11752 | + | ) | |
| 11753 | + | .expect("answered"); | |
| 11754 | + | assert_eq!(advanced.asked(), ["enable true", "enable false"]); | |
| 11755 | + | } | |
| 11756 | + | ||
| 11757 | + | #[test] | |
| 11758 | + | fn changing_the_mirror_location_is_the_folder_ask_storage_already_opened() { | |
| 11759 | + | let advanced = FakeAdvanced::unix(); | |
| 11760 | + | let response = | |
| 11761 | + | advancing(&advanced, Request::post("/settings/advanced/mirror/folder")).expect("answered"); | |
| 11762 | + | let Outcome::Locate(asking) = &response.outcome else { | |
| 11763 | + | panic!("expected an ask, got {:?}", response.outcome); | |
| 11764 | + | }; | |
| 11765 | + | assert_eq!( | |
| 11766 | + | asking.answers.route(), | |
| 11767 | + | Some("/settings/advanced/mirror/path") | |
| 11768 | + | ); | |
| 11769 | + | assert!(matches!(asking.sought, quasi_router::Sought::Folder)); | |
| 11770 | + | ||
| 11771 | + | let answered = Request::post("/settings/advanced/mirror/path") | |
| 11772 | + | .sending(Params::new().with("folder".to_owned(), "/mnt/fast/mirror".to_owned())); | |
| 11773 | + | advancing(&advanced, answered).expect("answered"); | |
| 11774 | + | assert_eq!(advanced.asked(), ["path /mnt/fast/mirror"]); | |
| 11775 | + | ||
| 11776 | + | // And an empty answer changes nothing, same as Storage's relocation. | |
| 11777 | + | advancing(&advanced, Request::post("/settings/advanced/mirror/path")).expect("answered"); | |
| 11778 | + | assert_eq!(advanced.asked().len(), 1); | |
| 11779 | + | } | |
| 11780 | + | ||
| 11781 | + | #[test] | |
| 11782 | + | fn a_mirror_setting_that_did_not_persist_says_so() { | |
| 11783 | + | // `set_mirror_enabled` applies the change in memory whatever happens, so a | |
| 11784 | + | // checkbox that took and one that will not survive a restart look | |
| 11785 | + | // identical. This is the difference, described. | |
| 11786 | + | let mut advanced = FakeAdvanced::unix(); | |
| 11787 | + | assert!(!said_deep(&advanced_screen(&advanced)).contains("not saved")); | |
| 11788 | + | ||
| 11789 | + | advanced.mirror.as_mut().expect("a mirror").failed = Some("permission denied".to_owned()); | |
| 11790 | + | let said = said_deep(&advanced_screen(&advanced)); | |
| 11791 | + | assert!( | |
| 11792 | + | said.contains( | |
| 11793 | + | "Mirror setting not saved: permission denied. It applies to this session only." | |
| 11794 | + | ), | |
| 11795 | + | "{said}" | |
| 11796 | + | ); | |
| 11797 | + | } | |
| 11798 | + |
Lines truncated
| @@ -1,0 +1,192 @@ | |||
| 1 | + | //! The rest of Settings > Advanced, described: importing a theme, and the | |
| 2 | + | //! library mirror. | |
| 3 | + | //! | |
| 4 | + | //! The last of the five sections the settings flip left behind. Advanced was | |
| 5 | + | //! the half-described one: Export current theme survived because | |
| 6 | + | //! [`Outcome::File`] answered it (`67881a88`), and these two did not. | |
| 7 | + | //! | |
| 8 | + | //! # The gap this was waiting on did not exist | |
| 9 | + | //! | |
| 10 | + | //! [`settings`](super::settings) recorded that Import Theme could not be | |
| 11 | + | //! described because "`FieldKind::File` says what may be picked, and nothing | |
| 12 | + | //! carries the picked file's *bytes* to a route on this host". Measured | |
| 13 | + | //! 2026-08-25 while counting that gap's consumers, it has none: | |
| 14 | + | //! `makeover::import_theme(&path, &custom_dir)` takes a **path**, validates the | |
| 15 | + | //! file itself, names it and copies it in. No bytes go anywhere near a route. | |
| 16 | + | //! | |
| 17 | + | //! [`Outcome::Locate`]'s `Sought::File { accept }` asks for exactly that, and | |
| 18 | + | //! `panel::locate` has served all three `Sought` shapes since quasi 0.60 with | |
| 19 | + | //! the accept-list plumbed through. So this is the same `locate`-then-act pair | |
| 20 | + | //! [`storage`](super::storage)'s relocation already uses, and the paragraph | |
| 21 | + | //! that held it up was written before the member it needed shipped. Third time | |
| 22 | + | //! that has happened in this set of five, after Storage and Trash. | |
| 23 | + | //! | |
| 24 | + | //! # A `cfg` is a host fact, so the host answers it | |
| 25 | + | //! | |
| 26 | + | //! The mirror builds a symlink tree and exists only on unix; the shipped | |
| 27 | + | //! section wrapped its whole block in `#[cfg(unix)]`. A description cannot | |
| 28 | + | //! carry a `cfg` and should not try: [`Advanced::mirror`] answers `None` where | |
| 29 | + | //! there is no mirror to have, and the section is not described there. The | |
| 30 | + | //! `cfg` lives in [`FromAdvanced`](super::FromAdvanced), which is the host. | |
| 31 | + | //! | |
| 32 | + | //! # The error is described, because the checkbox lies without it | |
| 33 | + | //! | |
| 34 | + | //! `set_mirror_enabled` applies the change in memory whatever happens and | |
| 35 | + | //! reports separately that the config write or the teardown failed. So a | |
| 36 | + | //! checkbox that took and a checkbox that will not survive a restart look | |
| 37 | + | //! identical, which is what the shipped warning banner existed to say and what | |
| 38 | + | //! [`Mirror::failed`](super::Mirror::failed) carries here. | |
| 39 | + | //! | |
| 40 | + | //! [`Advanced::mirror`]: super::Advanced::mirror | |
| 41 | + | //! [`Outcome::File`]: quasi_router::Outcome::File | |
| 42 | + | //! [`Outcome::Locate`]: quasi_router::Outcome::Locate | |
| 43 | + | ||
| 44 | + | use quasi_router::layout::{FieldKind, Notice, Tone}; | |
| 45 | + | use quasi_router::{ | |
| 46 | + | Accepted, Act, Action, Field, Locating, Node, Request, Response, RouteError, Router, Slot, | |
| 47 | + | Sought, | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | use super::Panels; | |
| 51 | + | ||
| 52 | + | /// The name a picked file comes back under. | |
| 53 | + | const FILE: &str = "file"; | |
| 54 | + | ||
| 55 | + | /// The name a picked folder comes back under. | |
| 56 | + | const FOLDER: &str = "folder"; | |
| 57 | + | ||
| 58 | + | /// Register the section's routes. | |
| 59 | + | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 60 | + | router | |
| 61 | + | .post("/settings/advanced/theme/import", import) | |
| 62 | + | .post("/settings/advanced/theme/imported", imported) | |
| 63 | + | .post("/settings/advanced/mirror/enabled", enable) | |
| 64 | + | .post("/settings/advanced/mirror/folder", folder) | |
| 65 | + | .post("/settings/advanced/mirror/path", path) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// `POST /settings/advanced/theme/import` | |
| 69 | + | /// | |
| 70 | + | /// The act shape of [`Outcome::Locate`](quasi_router::Outcome::Locate): picking | |
| 71 | + | /// the file *is* the import, so the call that lands does the importing. The | |
| 72 | + | /// form shape — where the answer is stashed and shown back — is what Storage's | |
| 73 | + | /// Choose folder uses, and it is the wrong one here because there is nothing to | |
| 74 | + | /// show and nothing to confirm afterwards. | |
| 75 | + | fn import(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 76 | + | Ok(Response::locate(Locating::new( | |
| 77 | + | Sought::File { | |
| 78 | + | accept: vec![Accepted::suffix(".toml")], | |
| 79 | + | }, | |
| 80 | + | "Import Theme", | |
| 81 | + | Action::post("/settings/advanced/theme/imported"), | |
| 82 | + | FILE, | |
| 83 | + | ))) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// `POST /settings/advanced/theme/imported` | |
| 87 | + | /// | |
| 88 | + | /// A reader who backed out of the picker has answered nothing: `ui::dialog` | |
| 89 | + | /// skips its handler on an empty result, so this is the second guard rather | |
| 90 | + | /// than the only one, and it is here because the address is reachable by | |
| 91 | + | /// typing. | |
| 92 | + | fn imported(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 93 | + | let file = request.payload.get(FILE).unwrap_or_default(); | |
| 94 | + | if !file.is_empty() { | |
| 95 | + | state.advanced.import_theme(file); | |
| 96 | + | } | |
| 97 | + | settled(state) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// `POST /settings/advanced/mirror/enabled` | |
| 101 | + | fn enable(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 102 | + | mirrored(state)?; | |
| 103 | + | let on = !request | |
| 104 | + | .payload | |
| 105 | + | .get("enabled") | |
| 106 | + | .unwrap_or_default() | |
| 107 | + | .is_empty(); | |
| 108 | + | state.advanced.enable_mirror(on); | |
| 109 | + | settled(state) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// `POST /settings/advanced/mirror/folder` | |
| 113 | + | fn folder(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 114 | + | mirrored(state)?; | |
| 115 | + | Ok(Response::locate(Locating::folder( | |
| 116 | + | "Choose library mirror location", | |
| 117 | + | Action::post("/settings/advanced/mirror/path"), | |
| 118 | + | FOLDER, | |
| 119 | + | ))) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// `POST /settings/advanced/mirror/path` | |
| 123 | + | fn path(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 124 | + | mirrored(state)?; | |
| 125 | + | let folder = request.payload.get(FOLDER).unwrap_or_default(); | |
| 126 | + | if !folder.is_empty() { | |
| 127 | + | state.advanced.set_mirror_path(folder); | |
| 128 | + | } | |
| 129 | + | settled(state) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Refuse where this host has no mirror. | |
| 133 | + | /// | |
| 134 | + | /// Every mirror address answers `NotFound` on Windows rather than quietly doing | |
| 135 | + | /// nothing, which is the same refusal `ConfigKey::from_key` makes for a setting | |
| 136 | + | /// that is not one: the address is reachable by typing on every host, and the | |
| 137 | + | /// screen that offers it is not drawn on this one. | |
| 138 | + | fn mirrored(state: &Panels<'_>) -> Result<(), RouteError> { | |
| 139 | + | if state.advanced.mirror().is_none() { | |
| 140 | + | return Err(RouteError::not_found("this host has no library mirror")); | |
| 141 | + | } | |
| 142 | + | Ok(()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// The settings window again, which is what every act here answers with. | |
| 146 | + | fn settled(state: &Panels<'_>) -> Result<Response, RouteError> { | |
| 147 | + | Ok(super::settings::screen(state)?.into()) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// The rest of the section, added below Export current theme. | |
| 151 | + | pub(super) fn section(body: Slot, state: &Panels<'_>) -> Slot { | |
| 152 | + | let body = body | |
| 153 | + | .with(Node::text( | |
| 154 | + | "Importing reads a theme .toml and adds it to your custom themes.", | |
| 155 | + | )) | |
| 156 | + | .with(Node::Act(Act::new( | |
| 157 | + | "Import theme...", | |
| 158 | + | Action::post("/settings/advanced/theme/import"), | |
| 159 | + | ))); | |
| 160 | + | ||
| 161 | + | let Some(mirror) = state.advanced.mirror() else { | |
| 162 | + | return body; | |
| 163 | + | }; | |
| 164 | + | ||
| 165 | + | let mut body = body | |
| 166 | + | .with(Node::section("Library Mirror")) | |
| 167 | + | .with(Node::text( | |
| 168 | + | "Create a symlink tree so DAWs can browse your library as a normal folder.", | |
| 169 | + | )) | |
| 170 | + | .with(Node::Field(Box::new( | |
| 171 | + | Field::new(FieldKind::Checkbox, "enabled", "Enable library mirror") | |
| 172 | + | .value(if mirror.enabled { "on" } else { "" }) | |
| 173 | + | .changes(Action::post("/settings/advanced/mirror/enabled")), | |
| 174 | + | ))) | |
| 175 | + | // Said before it is enabled rather than after, which is the shipped | |
| 176 | + | // section's own reasoning: the reader should know where the tree will | |
| 177 | + | // be built without hunting for a hidden config. | |
| 178 | + | .with(Node::text(format!("Mirror location: {}", mirror.shown))) | |
| 179 | + | .with(Node::Act(Act::new( | |
| 180 | + | "Change...", | |
| 181 | + | Action::post("/settings/advanced/mirror/folder"), | |
| 182 | + | ))); | |
| 183 | + | ||
| 184 | + | if let Some(failed) = mirror.failed { | |
| 185 | + | body = body.with(Node::Notice { | |
| 186 | + | kind: Notice::Banner, | |
| 187 | + | tone: Tone::Warning, | |
| 188 | + | text: format!("Mirror setting not saved: {failed}. It applies to this session only."), | |
| 189 | + | }); | |
| 190 | + | } | |
| 191 | + | body | |
| 192 | + | } |