Skip to main content

max / audiofiles

Describe Import Theme and the library mirror, the last of the five The fifth and last section the settings flip left behind. Advanced was the half-described one: Export current theme survived because `Outcome::File` answered it, and these two did not. Both are described now, in a module of the section's own, and the settings screen opens the heading and hands the rest over. Import Theme was waiting on a gap that turned out not to exist, which `85a1ba1` recorded. It is the act shape of `Outcome::Locate`: picking the file *is* the import, so the call that lands does the importing rather than stashing a path. Two routes, and the second guards an empty answer the way Storage's relocation does. A `cfg` is a host fact, so the host answers it. The mirror exists only on unix and the shipped section wrapped its block in `#[cfg(unix)]`. A description cannot carry one and should not try: `Advanced::mirror` is `None` where there is no mirror to have, the section is not described there, and all three mirror addresses refuse rather than quietly doing nothing -- a route table is not per-platform. The mirror's error is described rather than dropped. `set_mirror_enabled` applies the change in memory whatever happens and reports separately that the config write or the teardown failed, so a checkbox that took and one that will not survive a restart look identical. That is what the shipped warning banner existed to say. The Advanced heading is unconditional now. It appeared only when a theme was active, which was fine while export was the only thing under it and would have orphaned both of these. WHAT THE FIVE COST, TOGETHER, and it is the finding worth keeping: three of them were held up by a refusal that had gone stale, and none was re-read until somebody was made to count. Storage was "the filesystem" after `Outcome::Locate` shipped. Trash was "filesystem sizes and a destructive sweep" when the rows are the app's own table. Import Theme was "nothing carries the bytes" when nothing wanted bytes. The other two were not refusals: License exchanges no key, and the classifier was never a settings section. `settings`'s header says so now instead of listing what is missing. `cefe4159` -- delete the `quasi` feature -- has nothing left blocking it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 22:24 UTC
Signed with PGP, not checked
Commit: 0b84d36263df68fe1128952e7edab8ab2593d5eb
Parent: 85a1ba1
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 + }