Skip to main content

max / audiofiles

32.8 KB · 719 lines History Blame Raw
1 //! Consolidated Settings window: Storage, Appearance, Preview, Display, License.
2
3 use egui;
4
5 use crate::state::BrowserState;
6 use super::theme;
7 use super::widgets;
8
9 /// Draw the Settings window with collapsing sections.
10 pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) {
11 let mut open = state.settings.show_manager;
12 widgets::modal_window_with_open(
13 ctx,
14 "Settings",
15 Some(&mut open),
16 true,
17 Some(420.0),
18 |ui| {
19 egui::ScrollArea::vertical().show(ui, |ui| {
20 draw_storage_section(ui, state);
21 ui.add_space(theme::space::SM);
22 draw_appearance_section(ui, state);
23 ui.add_space(theme::space::SM);
24 draw_preview_section(ui, state);
25 ui.add_space(theme::space::SM);
26 draw_display_section(ui, state);
27 ui.add_space(theme::space::SM);
28 draw_license_section(ui, state);
29 ui.add_space(theme::space::SM);
30 draw_advanced_section(ui, state);
31 });
32 },
33 );
34 state.settings.show_manager = open;
35 }
36
37 /// Format byte counts as B/KB/MB/GB.
38 /// Collapse the user's home directory to `~` for display, so library paths
39 /// don't overflow narrow Settings windows. The full path is intended to be
40 /// surfaced as a tooltip on hover. Returns the original path string if the
41 /// home directory can't be resolved or the path doesn't sit under it.
42 fn collapse_home(path: &std::path::Path) -> String {
43 let display = path.display().to_string();
44 let Some(home) = dirs::home_dir() else { return display };
45 let home_str = home.display().to_string();
46 if let Some(rest) = display.strip_prefix(&home_str) {
47 if rest.is_empty() {
48 return "~".to_string();
49 }
50 return format!("~{rest}");
51 }
52 display
53 }
54
55 /// Format storage scan freshness. Returns `(text, stale)` — `stale` is true
56 /// when results are older than 24 hours, signalling the user should re-scan.
57 fn format_scan_age(age_secs: i64) -> (String, bool) {
58 let stale = age_secs >= 86_400;
59 let suffix = if stale { " — re-scan to refresh." } else { "" };
60 let text = if age_secs < 120 {
61 format!("Last scanned just now.{suffix}")
62 } else if age_secs < 3_600 {
63 format!("Last scanned {} minutes ago.{suffix}", age_secs / 60)
64 } else if age_secs < 86_400 {
65 format!("Last scanned {} hour{} ago.{suffix}", age_secs / 3_600, if age_secs / 3_600 == 1 { "" } else { "s" })
66 } else {
67 let days = age_secs / 86_400;
68 format!("Last scanned {} day{} ago.{suffix}", days, if days == 1 { "" } else { "s" })
69 };
70 (text, stale)
71 }
72
73 fn format_bytes(bytes: u64) -> String {
74 if bytes < 1024 {
75 format!("{bytes} B")
76 } else if bytes < 1024 * 1024 {
77 format!("{:.1} KB", bytes as f64 / 1024.0)
78 } else if bytes < 1024 * 1024 * 1024 {
79 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
80 } else {
81 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
82 }
83 }
84
85 // ── Storage section ──
86
87 fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
88 egui::CollapsingHeader::new(egui::RichText::new("Storage").strong())
89 .default_open(true)
90 .show(ui, |ui| {
91 ui.label(
92 egui::RichText::new("Each library is an independent sample collection with its own database and files. A library can contain multiple vaults (top-level browse buckets).")
93 .small()
94 .color(theme::text_muted()),
95 );
96 ui.add_space(theme::space::SM);
97
98 // Vault list
99 let vault_list = state.settings.list.clone();
100 let active_path = state.data_dir.clone();
101 let mut remove_path = None;
102 let mut should_close = false;
103
104 let mut relocate_old_path: Option<std::path::PathBuf> = None;
105 // Mirror the sidebar's switch flow: clicking a non-active reachable
106 // row switches that library. The sidebar ComboBox stays the primary
107 // entry point, but Settings rows visually read as clickable list
108 // rows (Phase 3's `selectable_row` widget) — wiring the click here
109 // closes the false-affordance gap without duplicating logic.
110 let mut switch_to: Option<(std::path::PathBuf, String)> = None;
111
112 for (name, path, reachable) in &vault_list {
113 let is_active = path == &active_path;
114 ui.horizontal(|ui| {
115 let status = if is_active {
116 egui::RichText::new("active").small().color(theme::accent_blue())
117 } else if !reachable {
118 egui::RichText::new("offline").small().color(theme::accent_red())
119 } else {
120 egui::RichText::new("").small()
121 };
122
123 let row_resp = widgets::selectable_row(ui, is_active, name);
124 if row_resp.clicked() && !is_active && *reachable {
125 switch_to = Some((path.clone(), name.clone()));
126 }
127 // Offline badge surfaces the last-known path on hover so the
128 // user knows where the directory used to live.
129 let status_label = ui.label(status);
130 if !reachable && !is_active {
131 status_label.on_hover_text(format!(
132 "Last known path: {}. Use Locate… to repoint if the directory moved.",
133 path.display(),
134 ));
135 }
136
137 if ui.small_button("Rename").clicked() {
138 state.settings.rename_target = Some((path.clone(), name.clone()));
139 }
140 // Locate… replaces a stranded registry entry's path. Only
141 // surfaced for offline non-active vaults; active vault path
142 // is handled differently (it's the open DB).
143 if !reachable && !is_active && ui.small_button("Locate…").on_hover_text("Point this library at a new directory").clicked() {
144 relocate_old_path = Some(path.clone());
145 }
146 if !is_active && widgets::danger_small_button(ui, "Remove").clicked() {
147 remove_path = Some(path.clone());
148 }
149 });
150 ui.label(
151 egui::RichText::new(collapse_home(path))
152 .small()
153 .color(theme::text_muted()),
154 )
155 .on_hover_text(path.display().to_string());
156 // Per-vault sample count + total size for the active vault when
157 // a fresh scan exists. Makes "which vault is the small one?"
158 // legible without opening each one (m-8). Only the active vault
159 // has a cache today — non-active rows stay path-only.
160 if is_active {
161 if let Some(ref stats) = state.settings.storage_cache {
162 ui.label(
163 egui::RichText::new(format!(
164 "{} samples \u{00B7} {}",
165 stats.sample_count,
166 format_bytes(stats.total_bytes),
167 ))
168 .small()
169 .color(theme::text_muted()),
170 );
171 }
172 }
173 ui.add_space(theme::space::SM);
174 }
175
176 if let Some((path, name)) = switch_to {
177 // Same guard as sidebar.rs: confirm only when in-flight work
178 // would be interrupted; otherwise switch directly. Closing
179 // Settings on switch matches the Create-New flow below.
180 if state.has_in_flight_work() {
181 state.pending_confirm = Some(
182 crate::state::ConfirmAction::SwitchLibrary {
183 path,
184 library_name: name,
185 },
186 );
187 } else {
188 state.settings.pending_action =
189 Some(crate::state::VaultAction::SwitchVault(path));
190 }
191 should_close = true;
192 }
193 if let Some(path) = remove_path {
194 state.settings.pending_action =
195 Some(crate::state::VaultAction::RemoveVault(path));
196 }
197 if let Some(old_path) = relocate_old_path {
198 if let Some(new_path) = rfd::FileDialog::new()
199 .set_title("Locate library directory")
200 .pick_folder()
201 {
202 state.settings.pending_action =
203 Some(crate::state::VaultAction::RelocateVault { old_path, new_path });
204 }
205 }
206
207 // Inline rename
208 if let Some((ref rename_path, _)) = state.settings.rename_target.clone() {
209 ui.separator();
210 ui.horizontal(|ui| {
211 ui.label("New name:");
212 let resp = ui.text_edit_singleline(
213 &mut state.settings.rename_target.as_mut().unwrap().1,
214 );
215 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
216 let new_name = state.settings.rename_target.as_ref().unwrap().1.clone();
217 if !new_name.trim().is_empty() {
218 state.settings.pending_action =
219 Some(crate::state::VaultAction::RenameVault {
220 path: rename_path.clone(),
221 new_name: new_name.trim().to_string(),
222 });
223 }
224 state.settings.rename_target = None;
225 }
226 if ui.button("Cancel").clicked() {
227 state.settings.rename_target = None;
228 }
229 });
230 }
231
232 // Storage stats
233 ui.add_space(theme::space::SM);
234 let scanning = matches!(
235 state.settings.pending_action,
236 Some(crate::state::VaultAction::ScanStorage),
237 );
238 ui.horizontal(|ui| {
239 let (label, hover) = if scanning {
240 ("Scanning...", "Scan in progress")
241 } else {
242 ("Scan", "Scan storage usage for this library")
243 };
244 if ui
245 .add_enabled(!scanning, egui::Button::new(label))
246 .on_hover_text(hover)
247 .clicked()
248 {
249 state.settings.pending_action = Some(crate::state::VaultAction::ScanStorage);
250 }
251 if scanning {
252 ui.spinner();
253 } else if let Some(ref stats) = state.settings.storage_cache {
254 ui.label(format!(
255 "{} samples, {} total, {} database",
256 stats.sample_count,
257 format_bytes(stats.total_bytes),
258 format_bytes(stats.db_bytes),
259 ));
260 }
261 });
262 // Surface scan freshness so stale cached numbers aren't trusted blindly.
263 if let Some(at) = state.settings.storage_cache_at {
264 let now = std::time::SystemTime::now()
265 .duration_since(std::time::UNIX_EPOCH)
266 .map(|d| d.as_secs() as i64)
267 .unwrap_or(at);
268 let age_secs = now.saturating_sub(at).max(0);
269 let (text, stale) = format_scan_age(age_secs);
270 let color = if stale { theme::accent_yellow() } else { theme::text_muted() };
271 ui.label(
272 egui::RichText::new(text).small().color(color),
273 );
274 }
275
276 ui.add_space(theme::space::MD);
277 ui.separator();
278 ui.add_space(theme::space::SM);
279
280 // Loose-files mode indicator for active vault
281 if state.settings.is_loose_files {
282 ui.add_space(theme::space::SM);
283 ui.label(
284 egui::RichText::new("This library uses loose-files mode. Samples are referenced in place, not duplicated.")
285 .small()
286 .color(theme::accent_yellow()),
287 );
288 }
289
290 // Create new library
291 ui.label(egui::RichText::new("Add Library").strong());
292 ui.horizontal(|ui| {
293 ui.label("Name:");
294 ui.text_edit_singleline(&mut state.settings.create_name);
295 });
296 ui.horizontal(|ui| {
297 if ui.button("Choose folder...").clicked() {
298 if let Some(path) = rfd::FileDialog::new().pick_folder() {
299 state.settings.create_path = Some(path);
300 }
301 }
302 if let Some(ref p) = state.settings.create_path {
303 ui.label(
304 egui::RichText::new(p.display().to_string())
305 .small()
306 .color(theme::text_secondary()),
307 );
308 }
309 });
310 // Storage style is a significant choice (copy vs reference in
311 // place) — promote it from a buried checkbox to an explicit radio
312 // choice so users opt into loose-files mode deliberately.
313 ui.label(egui::RichText::new("Storage style:").small().color(theme::text_secondary()));
314 let mut style = state.settings.create_loose_files;
315 if ui.radio_value(&mut style, false, "Copy samples into library (recommended)")
316 .on_hover_text("Samples are duplicated into the library's content-addressed store. Originals can be moved or deleted safely.")
317 .changed()
318 {
319 state.settings.create_loose_files = style;
320 }
321 if ui.radio_value(&mut style, true, "Reference samples in place (loose-files mode)")
322 .on_hover_text("Reference files in place instead of duplicating. Saves disk space but samples break if originals are moved or deleted. Cannot be changed later.")
323 .changed()
324 {
325 state.settings.create_loose_files = style;
326 }
327 if state.settings.create_loose_files {
328 ui.label(
329 egui::RichText::new("Moving or deleting originals will break references. This cannot be undone.")
330 .small()
331 .color(theme::accent_yellow()),
332 );
333 }
334 ui.add_space(theme::space::SM);
335 ui.horizontal(|ui| {
336 let can_create = !state.settings.create_name.trim().is_empty()
337 && state.settings.create_path.is_some();
338 let has_partial = !state.settings.create_name.trim().is_empty()
339 || state.settings.create_path.is_some();
340 if ui.add_enabled(can_create, egui::Button::new("Create New")).clicked() {
341 if let Some(path) = state.settings.create_path.take() {
342 let name = state.settings.create_name.trim().to_string();
343 let loose_files = state.settings.create_loose_files;
344 state.settings.pending_action =
345 Some(crate::state::VaultAction::CreateVault { name, path, loose_files });
346 state.settings.create_name.clear();
347 state.settings.create_loose_files = false;
348 should_close = true;
349 }
350 }
351 if ui
352 .add_enabled(can_create, egui::Button::new("Add Existing"))
353 .on_hover_text("Add an existing audiofiles library directory")
354 .clicked()
355 {
356 if let Some(path) = state.settings.create_path.take() {
357 let name = state.settings.create_name.trim().to_string();
358 state.settings.pending_action =
359 Some(crate::state::VaultAction::AddExistingVault { name, path });
360 state.settings.create_name.clear();
361 state.settings.create_loose_files = false;
362 // Both commit paths now close Settings: a Create makes
363 // the new vault active, and an Add-Existing typically
364 // motivates immediate browsing too.
365 should_close = true;
366 }
367 }
368 // Cancel only enabled when the form has user-entered state to
369 // discard — keeps the button from looking permanently active.
370 if ui
371 .add_enabled(has_partial, egui::Button::new("Cancel"))
372 .on_hover_text("Discard the form without creating a library")
373 .clicked()
374 {
375 state.settings.create_name.clear();
376 state.settings.create_path = None;
377 state.settings.create_loose_files = false;
378 }
379 });
380
381 if should_close {
382 state.settings.show_manager = false;
383 }
384 });
385 }
386
387 // ── Appearance section ──
388
389 fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) {
390 egui::CollapsingHeader::new(egui::RichText::new("Appearance").strong())
391 .default_open(false)
392 .show(ui, |ui| {
393 let themes = theme::list_themes();
394 let current_name = themes
395 .iter()
396 .find(|t| t.id == state.current_theme_id)
397 .map(|t| t.name.as_str())
398 .unwrap_or(&state.current_theme_id);
399
400 let mut new_theme_id = None;
401 ui.horizontal(|ui| {
402 ui.label("Theme:");
403 egui::ComboBox::from_id_salt("settings_theme_select")
404 .selected_text(current_name)
405 .width(200.0)
406 .show_ui(ui, |ui| {
407 for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] {
408 let group: Vec<&theme::ThemeMeta> = themes.iter().filter(|t| t.variant == variant).collect();
409 if group.is_empty() {
410 continue;
411 }
412 ui.label(egui::RichText::new(label).small().strong());
413 for t in group {
414 let is_selected = t.id == state.current_theme_id;
415 ui.horizontal(|ui| {
416 // Color swatch (bg + accent)
417 if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) {
418 let size = egui::vec2(12.0, 12.0);
419 let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
420 ui.painter().rect_filled(rect, 2.0, bg);
421 let accent_rect = egui::Rect::from_min_size(
422 rect.min + egui::vec2(6.0, 0.0),
423 egui::vec2(6.0, 12.0),
424 );
425 ui.painter().rect_filled(accent_rect, 0.0, accent);
426 }
427 let display = if t.is_custom {
428 format!("{} (custom)", t.name)
429 } else {
430 t.name.clone()
431 };
432 if ui.selectable_label(is_selected, display).clicked() {
433 new_theme_id = Some(t.id.clone());
434 }
435 });
436 }
437 ui.separator();
438 }
439 });
440 });
441
442 if let Some(id) = new_theme_id {
443 theme::set_theme(&id);
444 state.current_theme_id = id;
445 state.save_theme_preference();
446 }
447
448 });
449 }
450
451 // ── Preview section ──
452
453 fn draw_preview_section(ui: &mut egui::Ui, state: &mut BrowserState) {
454 egui::CollapsingHeader::new(egui::RichText::new("Preview").strong())
455 .default_open(false)
456 .show(ui, |ui| {
457 let mut loop_enabled = state.loop_enabled;
458 if ui.checkbox(&mut loop_enabled, "Loop playback")
459 .on_hover_text("Loop sample preview (L)")
460 .changed()
461 {
462 state.toggle_loop();
463 }
464
465 let mut autoplay = state.autoplay;
466 if ui.checkbox(&mut autoplay, "Auto-play on navigate")
467 .on_hover_text("Automatically preview sample when navigating")
468 .changed()
469 {
470 state.toggle_autoplay();
471 }
472 });
473 }
474
475 // ── Display section ──
476
477 fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) {
478 egui::CollapsingHeader::new(egui::RichText::new("Display").strong())
479 .default_open(false)
480 .show(ui, |ui| {
481 ui.label(egui::RichText::new("Visible Columns").small().color(theme::text_secondary()));
482
483 let mut col_changed = false;
484 col_changed |= ui.checkbox(&mut state.column_config.show_classification, "Classification").changed();
485 col_changed |= ui.checkbox(&mut state.column_config.show_bpm, "BPM").changed();
486 col_changed |= ui.checkbox(&mut state.column_config.show_key, "Key").changed();
487 col_changed |= ui.checkbox(&mut state.column_config.show_duration, "Duration").changed();
488 col_changed |= ui.checkbox(&mut state.column_config.show_peak_db, "Peak dB").changed();
489 col_changed |= ui.checkbox(&mut state.column_config.show_tags, "Tags").changed();
490 if col_changed {
491 state.save_column_config();
492 }
493
494 ui.add_space(theme::space::SM);
495 if ui
496 .button("Reset columns")
497 .on_hover_text(
498 "Restore column visibility, sort, and row density to defaults. \
499 Column widths reset on next app launch.",
500 )
501 .clicked()
502 {
503 state.reset_columns();
504 }
505
506 ui.add_space(theme::space::MD);
507 ui.separator();
508 ui.add_space(theme::space::SM);
509 ui.label(egui::RichText::new("Row Density").small().color(theme::text_secondary()));
510 let mut row_height = state.row_height;
511 let label = if row_height <= 22.0 {
512 "Compact"
513 } else if row_height >= 28.0 {
514 "Spacious"
515 } else {
516 "Normal"
517 };
518 ui.horizontal(|ui| {
519 ui.label(label);
520 ui.label(
521 egui::RichText::new(format!("{} px", row_height as i32))
522 .small()
523 .color(theme::text_muted()),
524 );
525 if ui.add(egui::Slider::new(&mut row_height, 20.0..=32.0).step_by(2.0).show_value(false)).changed() {
526 state.row_height = row_height;
527 let _ = state.backend.set_config("row_height", &format!("{row_height}"));
528 }
529 });
530
531 ui.add_space(theme::space::MD);
532 ui.separator();
533 ui.add_space(theme::space::SM);
534 ui.label(egui::RichText::new("Tag Suggestions").small().color(theme::text_secondary()));
535 let dismissed_total: usize = state
536 .dismissed_suggestions
537 .values()
538 .map(|v| v.len())
539 .sum();
540 ui.horizontal(|ui| {
541 ui.label(
542 egui::RichText::new(format!(
543 "{dismissed_total} dismissed suggestion{}",
544 if dismissed_total == 1 { "" } else { "s" }
545 ))
546 .small()
547 .color(theme::text_muted()),
548 );
549 if ui
550 .add_enabled(dismissed_total > 0, egui::Button::new("Reset suggestions"))
551 .on_hover_text("Re-enable every classification tag suggestion you've dismissed")
552 .clicked()
553 {
554 state.reset_dismissed_suggestions();
555 }
556 });
557 });
558 }
559
560 // ── License section ──
561
562 fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) {
563 egui::CollapsingHeader::new(egui::RichText::new("License").strong())
564 .default_open(false)
565 .show(ui, |ui| {
566 if let Some(ref masked) = state.settings.license_key_masked {
567 ui.horizontal(|ui| {
568 ui.label("Key:");
569 ui.label(egui::RichText::new(masked).color(theme::text_secondary()));
570 });
571 } else if let Some(days) = state.settings.trial_days_remaining {
572 // "Trial: 0 days" was technically correct but uncomfortably
573 // terse at the expired state; rephrase so the dead-end reads
574 // as a status, not a counter (m-13). A Purchase button would
575 // belong here but the buy flow is not yet wired.
576 let text = if days > 0 {
577 format!("Trial: {days} days left")
578 } else {
579 "Trial expired".to_string()
580 };
581 let color = if days > 7 {
582 theme::text_secondary()
583 } else if days > 0 {
584 theme::accent_yellow()
585 } else {
586 theme::text_muted()
587 };
588 ui.label(egui::RichText::new(text).color(color));
589 }
590 if let Some(ref mid) = state.settings.machine_id {
591 ui.horizontal(|ui| {
592 ui.label("Machine:");
593 // selectable_label so the value can be selected/copied with
594 // a keyboard shortcut, plus an explicit Copy button for
595 // pointer users. Common ask when contacting support (m-14).
596 ui.add(egui::Label::new(
597 egui::RichText::new(mid).small().color(theme::text_muted()),
598 ).selectable(true));
599 if ui.small_button("Copy").on_hover_text("Copy machine id to clipboard").clicked() {
600 ui.ctx().copy_text(mid.clone());
601 state.status = "Copied machine id.".to_string();
602 }
603 });
604 }
605 if state.settings.license_key_masked.is_some() {
606 ui.add_space(theme::space::MD);
607 if widgets::danger_button(ui, "Deactivate").clicked() {
608 state.settings.pending_action = Some(crate::state::VaultAction::DeactivateLicense);
609 }
610 }
611 });
612 }
613
614 // ── Advanced section ──
615
616 fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) {
617 egui::CollapsingHeader::new(egui::RichText::new("Advanced").strong())
618 .default_open(false)
619 .show(ui, |ui| {
620 // Theme import/export
621 ui.label(egui::RichText::new("Custom Themes").small().color(theme::text_secondary()));
622 ui.horizontal(|ui| {
623 if ui.button("Import Theme...").clicked() {
624 if let Some(path) = rfd::FileDialog::new()
625 .add_filter("Theme", &["toml"])
626 .pick_file()
627 {
628 let Some(custom_dir) = theme::custom_themes_dir() else {
629 state.status = "Theme import failed: no custom themes directory available.".to_string();
630 return;
631 };
632 match theme::load_theme(&path) {
633 Ok(_colors) => {
634 let id = path.file_stem()
635 .and_then(|s| s.to_str())
636 .unwrap_or("custom")
637 .to_string();
638 if let Err(e) = std::fs::create_dir_all(&custom_dir) {
639 tracing::error!("Failed to create custom themes dir: {e}");
640 state.status = format!("Theme import failed: {e}");
641 } else if let Err(e) = std::fs::copy(&path, custom_dir.join(format!("{id}.toml"))) {
642 tracing::error!("Failed to copy theme: {e}");
643 state.status = format!("Theme import failed: {e}");
644 } else {
645 theme::set_theme(&id);
646 state.current_theme_id = id.clone();
647 state.save_theme_preference();
648 state.status = format!("Imported theme: {id}");
649 }
650 }
651 Err(e) => {
652 tracing::error!("Failed to load theme: {e}");
653 state.status = format!("Theme import failed: {e}");
654 }
655 }
656 }
657 }
658 if ui.button("Export Current...").clicked() {
659 if let Some(path) = rfd::FileDialog::new()
660 .set_file_name(format!("{}.toml", state.current_theme_id))
661 .add_filter("Theme", &["toml"])
662 .save_file()
663 {
664 if let Some(content) = theme::export_theme_content(&state.current_theme_id) {
665 match std::fs::write(&path, content) {
666 Ok(()) => {
667 state.status = format!("Exported theme to {}", path.display());
668 }
669 Err(e) => {
670 tracing::error!("Failed to export theme: {e}");
671 state.status = format!("Theme export failed: {e}");
672 }
673 }
674 } else {
675 tracing::warn!("Theme '{}' not found for export", state.current_theme_id);
676 state.status = format!("Theme export failed: '{}' not found.", state.current_theme_id);
677 }
678 }
679 }
680 });
681
682 // Library mirror (Unix only)
683 #[cfg(unix)]
684 {
685 ui.add_space(theme::space::MD);
686 ui.separator();
687 ui.add_space(theme::space::SM);
688 ui.label(egui::RichText::new("Library Mirror").small().color(theme::text_secondary()));
689 let mut mirror = state.mirror_enabled;
690 if ui.checkbox(&mut mirror, "Enable library mirror")
691 .on_hover_text("Create a symlink tree so DAWs can browse your library as a normal folder")
692 .changed()
693 {
694 state.set_mirror_enabled(mirror);
695 }
696 // Always surface the mirror path so the user knows where the
697 // symlink tree will live before enabling, and can change it
698 // without hunting for a hidden config. Pairs with the path
699 // picker pattern in Add Library above (m-12).
700 ui.horizontal(|ui| {
701 ui.label(
702 egui::RichText::new(collapse_home(&state.mirror_path))
703 .small()
704 .color(theme::text_muted()),
705 )
706 .on_hover_text(state.mirror_path.display().to_string());
707 if ui.small_button("Change...").on_hover_text("Pick a new location for the mirror").clicked() {
708 if let Some(new_path) = rfd::FileDialog::new()
709 .set_title("Choose library mirror location")
710 .pick_folder()
711 {
712 state.set_mirror_path(new_path);
713 }
714 }
715 });
716 }
717 });
718 }
719