max / audiofiles
46 files changed,
+6607 insertions,
-1627 deletions
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! License activation screen, trial mode, and deactivation logic. | |
| 2 | 2 | ||
| 3 | + | use audiofiles_browser::ui::theme; | |
| 3 | 4 | use eframe::egui; | |
| 4 | 5 | ||
| 5 | 6 | use super::{AudioFilesApp, AppScreen, SYNC_SERVER_URL}; | |
| @@ -46,18 +47,61 @@ impl AudioFilesApp { | |||
| 46 | 47 | ||
| 47 | 48 | ui.vertical_centered(|ui| { | |
| 48 | 49 | ui.heading("audiofiles"); | |
| 49 | - | ui.add_space(8.0); | |
| 50 | - | ui.label("Enter your license key to get started."); | |
| 51 | - | ui.add_space(16.0); | |
| 50 | + | ui.add_space(theme::space::MD); | |
| 51 | + | ui.label("Start a free trial, or activate a license key."); | |
| 52 | + | ui.add_space(theme::space::SECTION); | |
| 53 | + | ||
| 54 | + | // Trial entry (primary path for first-time users) | |
| 55 | + | let trial_expired = matches!( | |
| 56 | + | self.trial_state, | |
| 57 | + | Some(ref t) if super::license::trial_days_remaining(t) <= 0 | |
| 58 | + | ); | |
| 59 | + | let trial_label = match self.trial_state { | |
| 60 | + | Some(ref trial) => { | |
| 61 | + | let days = super::license::trial_days_remaining(trial); | |
| 62 | + | if days > 0 { | |
| 63 | + | format!("Continue trial ({days} days left)") | |
| 64 | + | } else { | |
| 65 | + | "Trial expired".to_string() | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | None => "Start free trial — 14 days, no card".to_string(), | |
| 69 | + | }; | |
| 70 | + | let trial_btn = egui::Button::new(egui::RichText::new(trial_label).strong()); | |
| 71 | + | if ui.add_enabled(!trial_expired, trial_btn).clicked() { | |
| 72 | + | self.start_trial(); | |
| 73 | + | } | |
| 74 | + | if trial_expired { | |
| 75 | + | ui.add_space(theme::space::SM); | |
| 76 | + | ui.label( | |
| 77 | + | egui::RichText::new("Activate a license below to continue.") | |
| 78 | + | .small() | |
| 79 | + | .color(theme::text_secondary()), | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | ui.add_space(theme::space::XL); | |
| 84 | + | ui.separator(); | |
| 85 | + | ui.add_space(theme::space::MD); | |
| 86 | + | ||
| 87 | + | // License key entry | |
| 88 | + | ui.label( | |
| 89 | + | egui::RichText::new("Already have a license?") | |
| 90 | + | .color(theme::text_secondary()), | |
| 91 | + | ); | |
| 92 | + | ui.add_space(theme::space::SM); | |
| 52 | 93 | ||
| 53 | - | // Key input field (fixed width, centered) | |
| 54 | 94 | let input_width = 360.0_f32.min(available.x - 40.0); | |
| 55 | 95 | ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| { | |
| 56 | 96 | let response = ui.add_sized( | |
| 57 | 97 | ui.available_size(), | |
| 58 | 98 | egui::TextEdit::singleline(&mut self.license_key_input) | |
| 59 | - | .hint_text("bright-castle-forest-river-falcon"), | |
| 99 | + | .hint_text("five-word-license-key-example"), | |
| 60 | 100 | ); | |
| 101 | + | // Clear stale activation error as soon as the user edits the field. | |
| 102 | + | if response.changed() { | |
| 103 | + | self.activation_error = None; | |
| 104 | + | } | |
| 61 | 105 | // Submit on Enter | |
| 62 | 106 | if response.lost_focus() | |
| 63 | 107 | && ui.input(|i| i.key_pressed(egui::Key::Enter)) | |
| @@ -68,44 +112,52 @@ impl AudioFilesApp { | |||
| 68 | 112 | } | |
| 69 | 113 | }); | |
| 70 | 114 | ||
| 71 | - | ui.add_space(8.0); | |
| 115 | + | ui.add_space(theme::space::MD); | |
| 72 | 116 | ||
| 73 | 117 | let can_activate = !self.activating && !self.license_key_input.trim().is_empty(); | |
| 74 | - | let button_text = if self.activating { "Activating..." } else { "Activate" }; | |
| 75 | - | if ui.add_enabled(can_activate, egui::Button::new(button_text)).clicked() { | |
| 76 | - | self.start_activation(); | |
| 77 | - | } | |
| 118 | + | ui.horizontal(|ui| { | |
| 119 | + | let button_text = if self.activating { "Activating..." } else { "Activate" }; | |
| 120 | + | if ui.add_enabled(can_activate, egui::Button::new(button_text)).clicked() { | |
| 121 | + | self.start_activation(); | |
| 122 | + | } | |
| 123 | + | if self.activating { | |
| 124 | + | ui.spinner(); | |
| 125 | + | } | |
| 126 | + | }); | |
| 78 | 127 | ||
| 79 | - | if let Some(ref err) = self.activation_error { | |
| 80 | - | ui.add_space(8.0); | |
| 81 | - | ui.colored_label(egui::Color32::from_rgb(220, 60, 60), err); | |
| 128 | + | if let Some(err) = self.activation_error.clone() { | |
| 129 | + | ui.add_space(theme::space::MD); | |
| 130 | + | ui.colored_label(theme::accent_red(), err.to_string()); | |
| 131 | + | ui.add_space(theme::space::SM); | |
| 132 | + | // Per-class recovery affordance. | |
| 133 | + | match err { | |
| 134 | + | super::license::ActivationError::Network | |
| 135 | + | | super::license::ActivationError::Server(_) | |
| 136 | + | | super::license::ActivationError::Other(_) => { | |
| 137 | + | if ui.button("Try again").clicked() && !self.activating { | |
| 138 | + | self.start_activation(); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | super::license::ActivationError::InvalidKey => { | |
| 142 | + | ui.hyperlink_to( | |
| 143 | + | "Get a new license key", | |
| 144 | + | "https://makenot.work/store/audiofiles", | |
| 145 | + | ); | |
| 146 | + | } | |
| 147 | + | super::license::ActivationError::MachineLimit => { | |
| 148 | + | ui.hyperlink_to( | |
| 149 | + | "Contact support", | |
| 150 | + | "mailto:support@makenot.work?subject=License%20activation%20issue", | |
| 151 | + | ); | |
| 152 | + | } | |
| 153 | + | } | |
| 82 | 154 | } | |
| 83 | 155 | ||
| 84 | - | ui.add_space(16.0); | |
| 156 | + | ui.add_space(theme::space::MD); | |
| 85 | 157 | ui.hyperlink_to( | |
| 86 | 158 | "Get a license key", | |
| 87 | 159 | "https://makenot.work/store/audiofiles", | |
| 88 | 160 | ); | |
| 89 | - | ||
| 90 | - | // Trial button | |
| 91 | - | ui.add_space(24.0); | |
| 92 | - | ui.separator(); | |
| 93 | - | ui.add_space(8.0); | |
| 94 | - | ||
| 95 | - | let trial_label = if let Some(ref trial) = self.trial_state { | |
| 96 | - | let days = super::license::trial_days_remaining(trial); | |
| 97 | - | if days > 0 { | |
| 98 | - | format!("I am still testing the software ({days} days left)") | |
| 99 | - | } else { | |
| 100 | - | format!("I am still \"testing\" the software :) ({days} days)") | |
| 101 | - | } | |
| 102 | - | } else { | |
| 103 | - | "I am still testing the software".to_string() | |
| 104 | - | }; | |
| 105 | - | ||
| 106 | - | if ui.button(trial_label).clicked() { | |
| 107 | - | self.start_trial(); | |
| 108 | - | } | |
| 109 | 161 | }); | |
| 110 | 162 | }); | |
| 111 | 163 | } |
| @@ -27,14 +27,17 @@ pub enum AudioError { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | /// Build and start a cpal output stream that reads from the shared preview state. | |
| 30 | - | /// Returns `(stream, device_sample_rate)` — the stream handle must be kept alive. | |
| 30 | + | /// Returns `(stream, device_sample_rate, device_name)` — the stream handle must | |
| 31 | + | /// be kept alive. `device_name` is whatever cpal reports for the default | |
| 32 | + | /// output device; it's surfaced in the footer for diagnostic visibility. | |
| 31 | 33 | #[instrument(skip_all)] | |
| 32 | - | pub fn start_output_stream(shared: Arc<SharedState>) -> Result<(Stream, u32), AudioError> { | |
| 34 | + | pub fn start_output_stream(shared: Arc<SharedState>) -> Result<(Stream, u32, String), AudioError> { | |
| 33 | 35 | let host = cpal::default_host(); | |
| 34 | 36 | let device = host | |
| 35 | 37 | .default_output_device() | |
| 36 | 38 | .ok_or(AudioError::NoDevice)?; | |
| 37 | 39 | ||
| 40 | + | let device_name = device.name().unwrap_or_else(|_| "default".to_string()); | |
| 38 | 41 | let config = device.default_output_config()?; | |
| 39 | 42 | ||
| 40 | 43 | let channels = config.channels() as usize; | |
| @@ -66,7 +69,7 @@ pub fn start_output_stream(shared: Arc<SharedState>) -> Result<(Stream, u32), Au | |||
| 66 | 69 | }?; | |
| 67 | 70 | ||
| 68 | 71 | stream.play()?; | |
| 69 | - | Ok((stream, device_sample_rate)) | |
| 72 | + | Ok((stream, device_sample_rate, device_name)) | |
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | 75 | fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( |
| @@ -26,7 +26,7 @@ pub enum LicenseStatus { | |||
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | /// Shared slot for async activation results, polled each frame. | |
| 29 | - | pub type ActivationResult = Arc<Mutex<Option<Result<(), String>>>>; | |
| 29 | + | pub type ActivationResult = Arc<Mutex<Option<Result<(), ActivationError>>>>; | |
| 30 | 30 | ||
| 31 | 31 | // ── API request/response types ── | |
| 32 | 32 | ||
| @@ -115,16 +115,58 @@ pub fn remove_license(data_dir: &Path) -> io::Result<()> { | |||
| 115 | 115 | ||
| 116 | 116 | // ── HTTP activation/deactivation ── | |
| 117 | 117 | ||
| 118 | + | /// Classified activation failure for per-class UI messaging. | |
| 119 | + | #[derive(Debug, Clone)] | |
| 120 | + | pub enum ActivationError { | |
| 121 | + | /// Couldn't reach the activation server (DNS, timeout, connection refused). | |
| 122 | + | Network, | |
| 123 | + | /// HTTP non-2xx response from the server. | |
| 124 | + | Server(u16), | |
| 125 | + | /// Server rejected the key as unknown / malformed. | |
| 126 | + | InvalidKey, | |
| 127 | + | /// Key is already activated on another machine (or hit its activation limit). | |
| 128 | + | MachineLimit, | |
| 129 | + | /// Anything else (response parse error, unexpected message). | |
| 130 | + | Other(String), | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | impl std::fmt::Display for ActivationError { | |
| 134 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 135 | + | match self { | |
| 136 | + | Self::Network => write!(f, "Couldn't reach the activation server. Check your connection and try again."), | |
| 137 | + | Self::Server(code) => write!(f, "The activation server returned an error ({code}). Try again in a few minutes."), | |
| 138 | + | Self::InvalidKey => write!(f, "We didn't recognise that key. Double-check spelling, or get a new one."), | |
| 139 | + | Self::MachineLimit => write!(f, "This key is already in use on another machine. Deactivate it there first."), | |
| 140 | + | Self::Other(msg) => write!(f, "{msg}"), | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Classify a server-returned error string into a structured variant. Falls | |
| 146 | + | /// back to `Other` when no substring matches. The server's user-facing error | |
| 147 | + | /// strings are the only signal we have; if those strings change on the server | |
| 148 | + | /// side, this classifier needs updating. | |
| 149 | + | fn classify_server_error(msg: &str) -> ActivationError { | |
| 150 | + | let lower = msg.to_lowercase(); | |
| 151 | + | if lower.contains("machine") || lower.contains("already activated") || lower.contains("limit") { | |
| 152 | + | ActivationError::MachineLimit | |
| 153 | + | } else if lower.contains("invalid") || lower.contains("not found") || lower.contains("unknown") { | |
| 154 | + | ActivationError::InvalidKey | |
| 155 | + | } else { | |
| 156 | + | ActivationError::Other(msg.to_string()) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 118 | 160 | /// Activate a license key against the MNW API. | |
| 119 | 161 | /// | |
| 120 | 162 | /// Sends the key and machine ID to the server for validation. On success the | |
| 121 | - | /// server records an activation slot; on failure the returned error message | |
| 122 | - | /// is shown to the user. | |
| 123 | - | pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> { | |
| 163 | + | /// server records an activation slot; on failure the returned error is | |
| 164 | + | /// classified for per-class UI messaging. | |
| 165 | + | pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), ActivationError> { | |
| 124 | 166 | let client = reqwest::Client::builder() | |
| 125 | 167 | .timeout(std::time::Duration::from_secs(15)) | |
| 126 | 168 | .build() | |
| 127 | - | .map_err(|e| format!("HTTP client error: {e}"))?; | |
| 169 | + | .map_err(|_| ActivationError::Other("Couldn't initialise HTTP client".to_string()))?; | |
| 128 | 170 | ||
| 129 | 171 | let url = format!("{server_url}/api/keys/validate"); | |
| 130 | 172 | let body = ValidateRequest { | |
| @@ -138,21 +180,28 @@ pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Resu | |||
| 138 | 180 | .json(&body) | |
| 139 | 181 | .send() | |
| 140 | 182 | .await | |
| 141 | - | .map_err(|e| format!("Network error: {e}"))?; | |
| 183 | + | .map_err(|e| { | |
| 184 | + | if e.is_timeout() || e.is_connect() || e.is_request() { | |
| 185 | + | ActivationError::Network | |
| 186 | + | } else { | |
| 187 | + | ActivationError::Other(format!("Network error: {e}")) | |
| 188 | + | } | |
| 189 | + | })?; | |
| 142 | 190 | ||
| 143 | 191 | if !resp.status().is_success() { | |
| 144 | - | return Err(format!("Server returned {}", resp.status())); | |
| 192 | + | return Err(ActivationError::Server(resp.status().as_u16())); | |
| 145 | 193 | } | |
| 146 | 194 | ||
| 147 | 195 | let parsed: ValidateResponse = resp | |
| 148 | 196 | .json() | |
| 149 | 197 | .await | |
| 150 | - | .map_err(|e| format!("Invalid response: {e}"))?; | |
| 198 | + | .map_err(|e| ActivationError::Other(format!("Invalid response: {e}")))?; | |
| 151 | 199 | ||
| 152 | 200 | if parsed.valid { | |
| 153 | 201 | Ok(()) | |
| 154 | 202 | } else { | |
| 155 | - | Err(parsed.error.unwrap_or_else(|| "Invalid license key".to_string())) | |
| 203 | + | let msg = parsed.error.unwrap_or_else(|| "Invalid license key".to_string()); | |
| 204 | + | Err(classify_server_error(&msg)) | |
| 156 | 205 | } | |
| 157 | 206 | } | |
| 158 | 207 |
| @@ -28,6 +28,7 @@ use std::path::{Path, PathBuf}; | |||
| 28 | 28 | use std::sync::Arc; | |
| 29 | 29 | ||
| 30 | 30 | use audiofiles_browser::state::{BrowserState, SharedState}; | |
| 31 | + | use audiofiles_browser::ui::theme; | |
| 31 | 32 | use audiofiles_core::vault::{self, VaultRegistry}; | |
| 32 | 33 | use audiofiles_sync::{SyncKitConfig, SyncManager}; | |
| 33 | 34 | use eframe::egui; | |
| @@ -76,8 +77,9 @@ fn main() -> eframe::Result<()> { | |||
| 76 | 77 | ||
| 77 | 78 | // Start cpal audio output stream | |
| 78 | 79 | let _stream = match audio::start_output_stream(shared.clone()) { | |
| 79 | - | Ok((stream, device_rate)) => { | |
| 80 | + | Ok((stream, device_rate, device_name)) => { | |
| 80 | 81 | shared.device_sample_rate.store(device_rate, std::sync::atomic::Ordering::Relaxed); | |
| 82 | + | *shared.preview_device_name.lock() = Some(device_name); | |
| 81 | 83 | Some(stream) | |
| 82 | 84 | } | |
| 83 | 85 | Err(e) => { | |
| @@ -256,7 +258,7 @@ struct AudioFilesApp { | |||
| 256 | 258 | machine_id: String, | |
| 257 | 259 | license_key_input: String, | |
| 258 | 260 | activation_result: license::ActivationResult, | |
| 259 | - | activation_error: Option<String>, | |
| 261 | + | activation_error: Option<license::ActivationError>, | |
| 260 | 262 | activating: bool, | |
| 261 | 263 | license_cache: Option<license::LicenseCache>, | |
| 262 | 264 | trial_state: Option<license::TrialState>, | |
| @@ -465,7 +467,7 @@ impl eframe::App for AudioFilesApp { | |||
| 465 | 467 | if !notes.is_empty() { | |
| 466 | 468 | ui.label(¬es); | |
| 467 | 469 | } | |
| 468 | - | ui.add_space(4.0); | |
| 470 | + | ui.add_space(theme::space::SM); | |
| 469 | 471 | ui.horizontal(|ui| { | |
| 470 | 472 | if ui.button("Download").clicked() | |
| 471 | 473 | && crate::updater::is_trusted_download_url(&download_url) | |
| @@ -559,11 +561,37 @@ impl AudioFilesApp { | |||
| 559 | 561 | VaultAction::RenameVault { path, new_name } => { | |
| 560 | 562 | self.with_vault_registry(|reg| vault::rename_vault(reg, &path, &new_name)); | |
| 561 | 563 | } | |
| 564 | + | VaultAction::RelocateVault { old_path, new_path } => { | |
| 565 | + | // If we're repointing the active vault, switch to the new | |
| 566 | + | // path so the open browser picks up the new location. | |
| 567 | + | let was_active = self | |
| 568 | + | .vault_registry | |
| 569 | + | .as_ref() | |
| 570 | + | .map(|r| r.active == old_path) | |
| 571 | + | .unwrap_or(false); | |
| 572 | + | let ok = self.with_vault_registry(|reg| { | |
| 573 | + | vault::relocate_vault(reg, &old_path, &new_path) | |
| 574 | + | }); | |
| 575 | + | if ok && was_active { | |
| 576 | + | self.switch_vault(new_path); | |
| 577 | + | return; | |
| 578 | + | } | |
| 579 | + | } | |
| 562 | 580 | VaultAction::ScanStorage => { | |
| 581 | + | browser.settings.storage_scanning = true; | |
| 563 | 582 | match browser.backend.storage_stats() { | |
| 564 | - | Ok(stats) => browser.settings.storage_cache = Some(stats), | |
| 583 | + | Ok(stats) => { | |
| 584 | + | browser.settings.storage_cache = Some(stats); | |
| 585 | + | browser.settings.storage_cache_at = Some( | |
| 586 | + | std::time::SystemTime::now() | |
| 587 | + | .duration_since(std::time::UNIX_EPOCH) | |
| 588 | + | .map(|d| d.as_secs() as i64) | |
| 589 | + | .unwrap_or(0), | |
| 590 | + | ); | |
| 591 | + | } | |
| 565 | 592 | Err(e) => browser.status = format!("Storage scan failed: {e}"), | |
| 566 | 593 | } | |
| 594 | + | browser.settings.storage_scanning = false; | |
| 567 | 595 | } | |
| 568 | 596 | VaultAction::DeactivateLicense => { | |
| 569 | 597 | self.deactivate(); | |
| @@ -656,6 +684,42 @@ impl AudioFilesApp { | |||
| 656 | 684 | } | |
| 657 | 685 | } | |
| 658 | 686 | audiofiles_browser::editor::draw_browser(ctx, browser, self.sync_manager.as_ref()); | |
| 687 | + | ||
| 688 | + | // Drop target indicator: while files are hovering, paint a clear | |
| 689 | + | // border on top of the whole window plus a centered label. This is | |
| 690 | + | // the "yes, dropping here will work" feedback the OS doesn't give | |
| 691 | + | // us on Linux/Windows. Rendered as a foreground layer so it sits | |
| 692 | + | // above panel chrome but doesn't intercept clicks. | |
| 693 | + | if hovered_count > 0 { | |
| 694 | + | let screen = ctx.screen_rect(); | |
| 695 | + | let rect = screen.shrink(theme::space::MD); | |
| 696 | + | let painter = ctx.layer_painter(egui::LayerId::new( | |
| 697 | + | egui::Order::Foreground, | |
| 698 | + | egui::Id::new("drop_overlay"), | |
| 699 | + | )); | |
| 700 | + | painter.rect_stroke( | |
| 701 | + | rect, | |
| 702 | + | 4.0, | |
| 703 | + | egui::Stroke::new(2.0, theme::accent_blue()), | |
| 704 | + | egui::StrokeKind::Inside, | |
| 705 | + | ); | |
| 706 | + | let label = if hovered_count == 1 { | |
| 707 | + | "Drop to import".to_string() | |
| 708 | + | } else { | |
| 709 | + | format!("Drop to import {hovered_count} items") | |
| 710 | + | }; | |
| 711 | + | let label_pos = egui::pos2(rect.center().x, rect.top() + 32.0); | |
| 712 | + | // Background pill keeps the label readable on any theme. | |
| 713 | + | let bg_rect = egui::Rect::from_center_size(label_pos, egui::vec2(280.0, 36.0)); | |
| 714 | + | painter.rect_filled(bg_rect, 8.0, theme::bg_tertiary()); | |
| 715 | + | painter.text( | |
| 716 | + | label_pos, | |
| 717 | + | egui::Align2::CENTER_CENTER, | |
| 718 | + | &label, | |
| 719 | + | egui::FontId::proportional(18.0), | |
| 720 | + | theme::accent_blue(), | |
| 721 | + | ); | |
| 722 | + | } | |
| 659 | 723 | } else { | |
| 660 | 724 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 661 | 725 | ui.heading("audiofiles"); |
| @@ -444,6 +444,21 @@ impl Backend for DirectBackend { | |||
| 444 | 444 | Ok(tags::bulk_remove_tag(&db, hashes, tag)?) | |
| 445 | 445 | } | |
| 446 | 446 | ||
| 447 | + | fn rename_tag_globally(&self, old_tag: &str, new_tag: &str) -> BackendResult<usize> { | |
| 448 | + | let db = self.db.lock(); | |
| 449 | + | Ok(tags::rename_tag_globally(&db, old_tag, new_tag)?) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | fn count_samples_with_tag(&self, tag: &str) -> BackendResult<usize> { | |
| 453 | + | let db = self.db.lock(); | |
| 454 | + | Ok(tags::find_by_tag(&db, tag)?.len()) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | fn remove_tag_globally(&self, tag: &str) -> BackendResult<usize> { | |
| 458 | + | let db = self.db.lock(); | |
| 459 | + | Ok(tags::remove_tag_globally(&db, tag)?) | |
| 460 | + | } | |
| 461 | + | ||
| 447 | 462 | // --- Search --- | |
| 448 | 463 | ||
| 449 | 464 | fn search_in_folder( | |
| @@ -934,6 +949,12 @@ impl Backend for DirectBackend { | |||
| 934 | 949 | Ok(super::StorageStats { sample_count, total_bytes, db_bytes }) | |
| 935 | 950 | } | |
| 936 | 951 | ||
| 952 | + | fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)> { | |
| 953 | + | let db = self.db.lock(); | |
| 954 | + | db.vfs_storage_stats(vfs_id.as_i64()) | |
| 955 | + | .map_err(|e| BackendError::Other(e.to_string())) | |
| 956 | + | } | |
| 957 | + | ||
| 937 | 958 | fn poll_events(&self) -> Vec<BackendEvent> { | |
| 938 | 959 | let mut events = Vec::new(); | |
| 939 | 960 | ||
| @@ -941,6 +962,9 @@ impl Backend for DirectBackend { | |||
| 941 | 962 | if let Some(ref worker) = *self.import_worker.lock() { | |
| 942 | 963 | while let Some(event) = worker.try_recv() { | |
| 943 | 964 | match event { | |
| 965 | + | ImportEvent::WalkProgress { count, total_bytes } => { | |
| 966 | + | events.push(BackendEvent::ImportWalkProgress { count, total_bytes }); | |
| 967 | + | } | |
| 944 | 968 | ImportEvent::WalkComplete { total, total_bytes } => { | |
| 945 | 969 | events.push(BackendEvent::ImportWalkComplete { total, total_bytes }); | |
| 946 | 970 | } |
| @@ -45,6 +45,10 @@ pub enum BackendError { | |||
| 45 | 45 | #[derive(Debug, serde::Serialize, serde::Deserialize)] | |
| 46 | 46 | pub enum BackendEvent { | |
| 47 | 47 | // Import events | |
| 48 | + | ImportWalkProgress { | |
| 49 | + | count: usize, | |
| 50 | + | total_bytes: u64, | |
| 51 | + | }, | |
| 48 | 52 | ImportWalkComplete { | |
| 49 | 53 | total: usize, | |
| 50 | 54 | total_bytes: u64, | |
| @@ -249,6 +253,15 @@ pub trait Backend: Send + Sync { | |||
| 249 | 253 | /// Remove a tag from multiple samples. Returns count of tags removed. | |
| 250 | 254 | fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize>; | |
| 251 | 255 | ||
| 256 | + | /// Rename a tag everywhere it appears. Returns count of samples affected. | |
| 257 | + | fn rename_tag_globally(&self, old_tag: &str, new_tag: &str) -> BackendResult<usize>; | |
| 258 | + | ||
| 259 | + | /// Count samples that carry an exact tag (used for rename / remove preview). | |
| 260 | + | fn count_samples_with_tag(&self, tag: &str) -> BackendResult<usize>; | |
| 261 | + | ||
| 262 | + | /// Remove a tag from every sample that carries it. Returns count of samples affected. | |
| 263 | + | fn remove_tag_globally(&self, tag: &str) -> BackendResult<usize>; | |
| 264 | + | ||
| 252 | 265 | // --- Search --- | |
| 253 | 266 | ||
| 254 | 267 | /// Search within a specific VFS folder. | |
| @@ -456,6 +469,11 @@ pub trait Backend: Send + Sync { | |||
| 456 | 469 | /// Get aggregate storage statistics for the current vault. | |
| 457 | 470 | fn storage_stats(&self) -> BackendResult<StorageStats>; | |
| 458 | 471 | ||
| 472 | + | /// Per-VFS storage stats: `(unique_sample_count, total_bytes)`. Surfaced in | |
| 473 | + | /// the sync panel's per-VFS toggle rows so the user can see upload size | |
| 474 | + | /// before enabling blob sync for that vault. | |
| 475 | + | fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)>; | |
| 476 | + | ||
| 459 | 477 | /// Non-blocking poll for worker events. | |
| 460 | 478 | fn poll_events(&self) -> Vec<BackendEvent>; | |
| 461 | 479 | } |
| @@ -85,6 +85,9 @@ pub fn draw_browser( | |||
| 85 | 85 | ImportMode::ReviewErrors => { | |
| 86 | 86 | import_screens::draw_review_errors(ctx, state); | |
| 87 | 87 | } | |
| 88 | + | ImportMode::OperationCancelled { .. } => { | |
| 89 | + | import_screens::draw_operation_cancelled(ctx, state); | |
| 90 | + | } | |
| 88 | 91 | } | |
| 89 | 92 | ||
| 90 | 93 | // Overlays drawn on top of any screen | |
| @@ -112,6 +115,9 @@ pub fn draw_browser( | |||
| 112 | 115 | if state.show_loose_files_warning { | |
| 113 | 116 | overlays::draw_loose_files_warning(ctx, state); | |
| 114 | 117 | } | |
| 118 | + | if state.pending_import_preflight.is_some() { | |
| 119 | + | overlays::draw_import_preflight(ctx, state); | |
| 120 | + | } | |
| 115 | 121 | ||
| 116 | 122 | // Settings window | |
| 117 | 123 | if state.settings.show_manager { | |
| @@ -194,7 +200,7 @@ fn draw_normal_browser( | |||
| 194 | 200 | ||
| 195 | 201 | // Central file list | |
| 196 | 202 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 197 | - | file_list::draw_file_list(ui, state); | |
| 203 | + | file_list::draw_file_list(ui, state, sync_manager); | |
| 198 | 204 | }); | |
| 199 | 205 | } | |
| 200 | 206 | ||
| @@ -225,10 +231,28 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 225 | 231 | state.sync.show_panel = false; | |
| 226 | 232 | } else if state.bulk_modal.is_some() { | |
| 227 | 233 | state.close_bulk_modal(); | |
| 234 | + | } else if state.pending_import_preflight.is_some() { | |
| 235 | + | state.cancel_import_preflight(); | |
| 228 | 236 | } else if state.pending_confirm.is_some() { | |
| 229 | 237 | state.dismiss_confirm(); | |
| 230 | 238 | } else if state.show_help { | |
| 231 | 239 | state.show_help = false; | |
| 240 | + | } else if matches!( | |
| 241 | + | state.import_mode, | |
| 242 | + | ImportMode::ConfigureImport { .. } | |
| 243 | + | | ImportMode::TagFolders { .. } | |
| 244 | + | | ImportMode::ConfigureAnalysis { .. } | |
| 245 | + | | ImportMode::ReviewSuggestions { .. } | |
| 246 | + | | ImportMode::ReviewErrors | |
| 247 | + | ) { | |
| 248 | + | // Safe import-wizard screens (no in-flight work) — Escape backs out. | |
| 249 | + | // Active modes (Importing/Analyzing/Cleaning/Exporting) require the | |
| 250 | + | // explicit Cancel button to avoid losing in-progress work. | |
| 251 | + | state.cancel_import(); | |
| 252 | + | } else if matches!(state.import_mode, ImportMode::OperationCancelled { .. }) { | |
| 253 | + | // Cancel-acknowledgement (C-3) dismisses on Escape just like the | |
| 254 | + | // Done button — the file work has already been cancelled. | |
| 255 | + | state.import_mode = ImportMode::None; | |
| 232 | 256 | } else if !state.search_query.is_empty() { | |
| 233 | 257 | state.search_query.clear(); | |
| 234 | 258 | state.apply_search(); | |
| @@ -253,10 +277,27 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 253 | 277 | state.confirm_delete_selected(); | |
| 254 | 278 | } | |
| 255 | 279 | ||
| 256 | - | // Cmd+A: select all | |
| 257 | - | if input.modifiers.command && input.key_pressed(egui::Key::A) { | |
| 280 | + | // Cmd+A: select all (skip the ".." parent row when present — it's not a sample | |
| 281 | + | // and must never be part of a bulk operation). | |
| 282 | + | if input.modifiers.command && input.key_pressed(egui::Key::A) && !input.modifiers.shift { | |
| 258 | 283 | let len = state.visible_len(); | |
| 259 | - | state.selection.select_all(len); | |
| 284 | + | let start = if state.current_dir.is_some() { 1 } else { 0 }; | |
| 285 | + | state.selection.select_all_from(start, len); | |
| 286 | + | return; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Cmd+Shift+I: invert selection (over sample rows; parent row excluded). | |
| 290 | + | if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::I) { | |
| 291 | + | state.invert_selection(); | |
| 292 | + | return; | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // Tab: focus the detail-panel tag input (opens the detail panel if hidden). | |
| 296 | + | if input.key_pressed(egui::Key::Tab) && !input.modifiers.shift { | |
| 297 | + | if !state.detail_visible { | |
| 298 | + | state.detail_visible = true; | |
| 299 | + | } | |
| 300 | + | state.focus_tag_input = true; | |
| 260 | 301 | return; | |
| 261 | 302 | } | |
| 262 | 303 | ||
| @@ -310,7 +351,15 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 310 | 351 | } | |
| 311 | 352 | } | |
| 312 | 353 | if input.key_pressed(egui::Key::Backspace) || input.key_pressed(egui::Key::ArrowLeft) { | |
| 313 | - | state.go_up(); | |
| 354 | + | // Two-step Backspace: while in similarity / duplicate mode, the | |
| 355 | + | // first press exits the mode; a follow-up press then falls through | |
| 356 | + | // to "go up one folder." Matches the "Esc closes the dialog, then | |
| 357 | + | // Esc closes the parent" muscle memory. | |
| 358 | + | if state.similarity_search_hash.is_some() { | |
| 359 | + | state.clear_similarity_search(); | |
| 360 | + | } else { | |
| 361 | + | state.go_up(); | |
| 362 | + | } | |
| 314 | 363 | } | |
| 315 | 364 | if input.key_pressed(egui::Key::Space) { | |
| 316 | 365 | state.toggle_preview(); | |
| @@ -364,8 +413,8 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 364 | 413 | } | |
| 365 | 414 | } | |
| 366 | 415 | } | |
| 367 | - | // Cmd+M: bulk move | |
| 368 | - | if input.modifiers.command && input.key_pressed(egui::Key::M) { | |
| 416 | + | // Cmd+Shift+M: bulk move (Cmd+M alone conflicts with macOS minimize) | |
| 417 | + | if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::M) { | |
| 369 | 418 | if state.selection.count() > 1 { | |
| 370 | 419 | state.open_bulk_move_modal(); | |
| 371 | 420 | } |
| @@ -8,6 +8,7 @@ use std::fs; | |||
| 8 | 8 | use std::path::{Path, PathBuf}; | |
| 9 | 9 | use std::sync::{mpsc, Mutex}; | |
| 10 | 10 | use std::thread; | |
| 11 | + | use std::time::{Duration, Instant}; | |
| 11 | 12 | ||
| 12 | 13 | use tracing::{error, instrument, warn}; | |
| 13 | 14 | ||
| @@ -38,6 +39,7 @@ pub enum ImportStrategy { | |||
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | /// A top-level source folder and its imported samples. | |
| 42 | + | #[derive(Clone)] | |
| 41 | 43 | pub struct ImportedFolder { | |
| 42 | 44 | pub name: String, | |
| 43 | 45 | pub samples: Vec<(String, String)>, // (hash, ext) | |
| @@ -64,6 +66,10 @@ pub enum ImportCommand { | |||
| 64 | 66 | ||
| 65 | 67 | /// Event sent from the import worker back to the GUI thread. | |
| 66 | 68 | pub enum ImportEvent { | |
| 69 | + | /// Throttled progress emitted during the pre-walk so the UI can show a | |
| 70 | + | /// running file count instead of an indeterminate spinner (m-12). Fires | |
| 71 | + | /// at most every ~100ms. | |
| 72 | + | WalkProgress { count: usize, total_bytes: u64 }, | |
| 67 | 73 | /// Pre-walk finished — we now know the total file count and size. | |
| 68 | 74 | WalkComplete { total: usize, total_bytes: u64 }, | |
| 69 | 75 | /// One file was processed. | |
| @@ -142,10 +148,16 @@ pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> std::io::Re | |||
| 142 | 148 | /// Recursively count audio files and sum their sizes under `dir`. | |
| 143 | 149 | /// Checks for cancellation between entries. Returns `None` if cancelled. | |
| 144 | 150 | #[instrument(skip_all)] | |
| 145 | - | fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<(usize, u64)> { | |
| 151 | + | fn count_audio_files( | |
| 152 | + | dir: &Path, | |
| 153 | + | cmd_rx: &mpsc::Receiver<ImportCommand>, | |
| 154 | + | event_tx: &mpsc::Sender<ImportEvent>, | |
| 155 | + | ) -> Option<(usize, u64)> { | |
| 146 | 156 | let mut count = 0; | |
| 147 | 157 | let mut total_bytes = 0u64; | |
| 148 | 158 | let mut stack = vec![dir.to_path_buf()]; | |
| 159 | + | let mut last_emit = Instant::now(); | |
| 160 | + | let emit_interval = Duration::from_millis(100); | |
| 149 | 161 | ||
| 150 | 162 | while let Some(current) = stack.pop() { | |
| 151 | 163 | // Check for cancel | |
| @@ -172,6 +184,10 @@ fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Opti | |||
| 172 | 184 | if let Ok(meta) = fs::metadata(&path) { | |
| 173 | 185 | total_bytes += meta.len(); | |
| 174 | 186 | } | |
| 187 | + | if last_emit.elapsed() >= emit_interval { | |
| 188 | + | let _ = event_tx.send(ImportEvent::WalkProgress { count, total_bytes }); | |
| 189 | + | last_emit = Instant::now(); | |
| 190 | + | } | |
| 175 | 191 | } | |
| 176 | 192 | } | |
| 177 | 193 | } | |
| @@ -529,7 +545,7 @@ fn worker_loop( | |||
| 529 | 545 | }; | |
| 530 | 546 | ||
| 531 | 547 | // Phase 1: pre-walk to count audio files and sum sizes | |
| 532 | - | let (total, total_bytes) = match count_audio_files(&source, &cmd_rx) { | |
| 548 | + | let (total, total_bytes) = match count_audio_files(&source, &cmd_rx, &event_tx) { | |
| 533 | 549 | Some(result) => result, | |
| 534 | 550 | None => { | |
| 535 | 551 | let _ = event_tx.send(ImportEvent::Complete { | |
| @@ -639,6 +655,7 @@ mod tests { | |||
| 639 | 655 | #[test] | |
| 640 | 656 | fn import_event_variants_constructible() { | |
| 641 | 657 | let _walk = ImportEvent::WalkComplete { total: 42, total_bytes: 1024 }; | |
| 658 | + | let _walk_progress = ImportEvent::WalkProgress { count: 17, total_bytes: 512 }; | |
| 642 | 659 | let _progress = ImportEvent::Progress { | |
| 643 | 660 | completed: 5, | |
| 644 | 661 | total: 42, |
| @@ -25,16 +25,16 @@ impl BrowserState { | |||
| 25 | 25 | ||
| 26 | 26 | let action = self.pending_confirm.take(); | |
| 27 | 27 | match action { | |
| 28 | - | Some(ConfirmAction::DeleteNode { node_id, .. }) => { | |
| 28 | + | Some(ConfirmAction::DeleteNode { node_id, node_name }) => { | |
| 29 | 29 | match self.backend.delete_node(node_id) { | |
| 30 | 30 | Ok(()) => { | |
| 31 | 31 | self.refresh_contents(); | |
| 32 | - | self.status = "Deleted".to_string(); | |
| 32 | + | self.status = format!("Deleted \"{node_name}\""); | |
| 33 | 33 | } | |
| 34 | 34 | Err(e) => self.status = format!("Delete failed: {e}"), | |
| 35 | 35 | } | |
| 36 | 36 | } | |
| 37 | - | Some(ConfirmAction::DeleteVfs { vfs_id, .. }) => { | |
| 37 | + | Some(ConfirmAction::DeleteVfs { vfs_id, vfs_name }) => { | |
| 38 | 38 | match self.backend.delete_vfs(vfs_id) { | |
| 39 | 39 | Ok(()) => { | |
| 40 | 40 | // Start background cleanup for orphaned samples | |
| @@ -49,16 +49,73 @@ impl BrowserState { | |||
| 49 | 49 | // Fallback: synchronous cleanup | |
| 50 | 50 | let orphans = self.backend.remove_orphaned_samples().unwrap_or(0); | |
| 51 | 51 | self.refresh_vfs_list(); | |
| 52 | + | // Note: deleting a VFS row (a sub-vault inside the | |
| 53 | + | // current library) is what `delete_vfs` does. The | |
| 54 | + | // user-facing word for that inner concept is still | |
| 55 | + | // "vault" — we only renamed the registry-level | |
| 56 | + | // concept to "library." | |
| 52 | 57 | if orphans > 0 { | |
| 53 | - | self.status = format!("Library deleted ({orphans} samples removed)"); | |
| 58 | + | self.status = format!("Vault \"{vfs_name}\" deleted ({orphans} samples removed)"); | |
| 54 | 59 | } else { | |
| 55 | - | self.status = "Library deleted".to_string(); | |
| 60 | + | self.status = format!("Vault \"{vfs_name}\" deleted"); | |
| 56 | 61 | } | |
| 57 | 62 | } | |
| 58 | 63 | } | |
| 59 | 64 | Err(e) => self.status = format!("Delete failed: {e}"), | |
| 60 | 65 | } | |
| 61 | 66 | } | |
| 67 | + | Some(ConfirmAction::SwitchLibrary { path, .. }) => { | |
| 68 | + | // User confirmed despite in-flight work — set the action; | |
| 69 | + | // the app layer's SwitchVault handler will tear down the | |
| 70 | + | // current browser (cancelling in-flight workers in the process). | |
| 71 | + | self.settings.pending_action = | |
| 72 | + | Some(crate::state::VaultAction::SwitchVault(path)); | |
| 73 | + | } | |
| 74 | + | Some(ConfirmAction::RemoveTagGlobally { tag }) => { | |
| 75 | + | match self.backend.remove_tag_globally(&tag) { | |
| 76 | + | Ok(count) => { | |
| 77 | + | self.refresh_all_tags(); | |
| 78 | + | self.search_filter.required_tags.retain(|t| t != &tag); | |
| 79 | + | self.apply_search(); | |
| 80 | + | self.status = format!("Removed tag \"{tag}\" from {count} sample{}", | |
| 81 | + | if count == 1 { "" } else { "s" }); | |
| 82 | + | } | |
| 83 | + | Err(e) => self.status = format!("Remove tag failed: {e}"), | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | Some(ConfirmAction::DeleteCollection { coll_id, coll_name }) => { | |
| 87 | + | match self.backend.delete_collection(coll_id) { | |
| 88 | + | Ok(()) => { | |
| 89 | + | // Deactivate if the deleted collection was the active one. | |
| 90 | + | if self.active_collection == Some(coll_id) { | |
| 91 | + | self.deactivate_collection(); | |
| 92 | + | } | |
| 93 | + | self.refresh_collections(); | |
| 94 | + | self.status = format!("Deleted collection: {coll_name}"); | |
| 95 | + | } | |
| 96 | + | Err(e) => self.status = format!("Delete failed: {e}"), | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | Some(ConfirmAction::ReanalyzeOverwrite { sample_hashes, .. }) => { | |
| 100 | + | self.start_analysis_flow(sample_hashes); | |
| 101 | + | } | |
| 102 | + | Some(ConfirmAction::DisconnectSync { .. }) => { | |
| 103 | + | // The actual sync.disconnect() happens in sync_panel.rs next | |
| 104 | + | // frame — bulk_ops.rs runs without a SyncManager handle. | |
| 105 | + | self.sync.pending_disconnect = true; | |
| 106 | + | } | |
| 107 | + | Some(ConfirmAction::RemoveFailedSamples { single_index, .. }) => { | |
| 108 | + | match single_index { | |
| 109 | + | Some(idx) => self.remove_failed_sample(idx), | |
| 110 | + | None => self.remove_all_failed_samples(), | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | Some(ConfirmAction::ReverseSamples { .. }) => { | |
| 114 | + | // m-16: confirmed large-batch Reverse. The unconfirmed path | |
| 115 | + | // for selections ≤10 calls batch_reverse() directly from the | |
| 116 | + | // edit panel; only the gated case routes through here. | |
| 117 | + | self.batch_reverse(); | |
| 118 | + | } | |
| 62 | 119 | Some(ConfirmAction::DeleteMultiple { .. }) => unreachable!(), | |
| 63 | 120 | None => {} | |
| 64 | 121 | } | |
| @@ -119,7 +176,7 @@ impl BrowserState { | |||
| 119 | 176 | } | |
| 120 | 177 | ||
| 121 | 178 | /// Push an undoable operation onto the stack (capped at 100 entries). | |
| 122 | - | fn push_undo(&mut self, op: UndoOp) { | |
| 179 | + | pub(crate) fn push_undo(&mut self, op: UndoOp) { | |
| 123 | 180 | self.undo_stack.push(op); | |
| 124 | 181 | // Cap at 100 entries to bound memory usage. Each UndoOp can hold a full | |
| 125 | 182 | // subtree snapshot (nodes + tags), so unbounded growth is not acceptable. | |
| @@ -174,9 +231,14 @@ impl BrowserState { | |||
| 174 | 231 | let _ = self.backend.bulk_add_tag(&refs, &tag); | |
| 175 | 232 | self.status = "Undo: re-added tag".to_string(); | |
| 176 | 233 | } | |
| 234 | + | UndoOp::TagRemove { hash, tag } => { | |
| 235 | + | let _ = self.backend.add_tag(&hash, &tag); | |
| 236 | + | self.status = format!("Undo: restored tag \"{tag}\""); | |
| 237 | + | } | |
| 177 | 238 | } | |
| 178 | 239 | self.refresh_contents(); | |
| 179 | 240 | self.refresh_all_tags(); | |
| 241 | + | self.refresh_selected_tags(); | |
| 180 | 242 | } | |
| 181 | 243 | ||
| 182 | 244 | /// Close the active bulk operation modal without applying changes. | |
| @@ -308,6 +370,53 @@ impl BrowserState { | |||
| 308 | 370 | self.refresh_all_tags(); | |
| 309 | 371 | } | |
| 310 | 372 | ||
| 373 | + | /// Apply a tag to an arbitrary list of sample hashes and push an undo entry. | |
| 374 | + | /// Used by the multi-summary's right-click "Apply to remaining" affordance | |
| 375 | + | /// (M-11), which targets the subset that doesn't yet carry the tag. | |
| 376 | + | pub fn apply_tag_to_hashes(&mut self, tag: &str, hashes: &[String]) { | |
| 377 | + | if hashes.is_empty() || tag.is_empty() { | |
| 378 | + | return; | |
| 379 | + | } | |
| 380 | + | let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); | |
| 381 | + | match self.backend.bulk_add_tag(&refs, tag) { | |
| 382 | + | Ok(count) => { | |
| 383 | + | self.push_undo(UndoOp::BulkTagAdd { | |
| 384 | + | tag: tag.to_string(), | |
| 385 | + | hashes: hashes.to_vec(), | |
| 386 | + | }); | |
| 387 | + | self.status = format!("Tagged {count} samples with \"{tag}\""); | |
| 388 | + | } | |
| 389 | + | Err(e) => { | |
| 390 | + | self.status = format!("Tag error: {e}"); | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | self.refresh_selected_tags(); | |
| 394 | + | self.refresh_all_tags(); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | /// Remove a tag from an arbitrary list of sample hashes and push an undo entry. | |
| 398 | + | /// Companion to `apply_tag_to_hashes` for the multi-summary badges (M-11). | |
| 399 | + | pub fn remove_tag_from_hashes(&mut self, tag: &str, hashes: &[String]) { | |
| 400 | + | if hashes.is_empty() || tag.is_empty() { | |
| 401 | + | return; | |
| 402 | + | } | |
| 403 | + | let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); | |
| 404 | + | match self.backend.bulk_remove_tag(&refs, tag) { | |
| 405 | + | Ok(count) => { | |
| 406 | + | self.push_undo(UndoOp::BulkTagRemove { | |
| 407 | + | tag: tag.to_string(), | |
| 408 | + | hashes: hashes.to_vec(), | |
| 409 | + | }); | |
| 410 | + | self.status = format!("Removed tag \"{tag}\" from {count} samples"); | |
| 411 | + | } | |
| 412 | + | Err(e) => { | |
| 413 | + | self.status = format!("Tag error: {e}"); | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | self.refresh_selected_tags(); | |
| 417 | + | self.refresh_all_tags(); | |
| 418 | + | } | |
| 419 | + | ||
| 311 | 420 | /// Move all selected nodes to the chosen directory. Snapshots old parents for undo. | |
| 312 | 421 | pub fn execute_bulk_move(&mut self) { | |
| 313 | 422 | let (node_ids, selected_idx, directories) = match &self.bulk_modal { | |
| @@ -387,7 +496,11 @@ impl BrowserState { | |||
| 387 | 496 | } | |
| 388 | 497 | ||
| 389 | 498 | self.push_undo(UndoOp::BulkRename { renames }); | |
| 390 | - | self.status = format!("Renamed {} items", new_stems.len()); | |
| 499 | + | self.status = format!( | |
| 500 | + | "Renamed {} items (pattern: {})", | |
| 501 | + | new_stems.len(), | |
| 502 | + | pattern_input | |
| 503 | + | ); | |
| 391 | 504 | self.bulk_modal = None; | |
| 392 | 505 | self.refresh_contents(); | |
| 393 | 506 | } |
| @@ -6,7 +6,15 @@ use super::*; | |||
| 6 | 6 | ||
| 7 | 7 | /// Count audio files in a directory (recursive). Used for the import dry-run preview. | |
| 8 | 8 | fn count_audio_files(dir: &Path) -> usize { | |
| 9 | - | let mut count = 0; | |
| 9 | + | walk_folder_stats(dir).0 | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | /// Walk a directory and return `(audio_file_count, total_bytes)`. Used by the | |
| 13 | + | /// Quick-Import preflight to decide whether to prompt for confirmation on | |
| 14 | + | /// large imports. | |
| 15 | + | fn walk_folder_stats(dir: &Path) -> (usize, u64) { | |
| 16 | + | let mut count = 0usize; | |
| 17 | + | let mut bytes = 0u64; | |
| 10 | 18 | let mut dirs = vec![dir.to_path_buf()]; | |
| 11 | 19 | while let Some(d) = dirs.pop() { | |
| 12 | 20 | if let Ok(entries) = std::fs::read_dir(&d) { | |
| @@ -18,11 +26,30 @@ fn count_audio_files(dir: &Path) -> usize { | |||
| 18 | 26 | } | |
| 19 | 27 | } else if audiofiles_core::util::is_audio_file(&path) { | |
| 20 | 28 | count += 1; | |
| 29 | + | if let Ok(md) = entry.metadata() { | |
| 30 | + | bytes = bytes.saturating_add(md.len()); | |
| 31 | + | } | |
| 21 | 32 | } | |
| 22 | 33 | } | |
| 23 | 34 | } | |
| 24 | 35 | } | |
| 25 | - | count | |
| 36 | + | (count, bytes) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Thresholds above which Quick-Import prompts for confirmation before | |
| 40 | + | /// starting. Below either limit the import begins immediately to keep the | |
| 41 | + | /// common small-import path frictionless. | |
| 42 | + | const QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD: usize = 100; | |
| 43 | + | const QUICK_IMPORT_PREFLIGHT_BYTE_THRESHOLD: u64 = 1_073_741_824; // 1 GiB | |
| 44 | + | ||
| 45 | + | /// Pending Quick-Import that the user must confirm before files are touched. | |
| 46 | + | /// Surfaced via the preflight modal when the folder is large enough to make | |
| 47 | + | /// an accidental commit costly. | |
| 48 | + | #[derive(Debug, Clone)] | |
| 49 | + | pub struct ImportPreflight { | |
| 50 | + | pub source: PathBuf, | |
| 51 | + | pub file_count: usize, | |
| 52 | + | pub total_bytes: u64, | |
| 26 | 53 | } | |
| 27 | 54 | ||
| 28 | 55 | impl BrowserState { | |
| @@ -192,28 +219,70 @@ impl BrowserState { | |||
| 192 | 219 | ||
| 193 | 220 | self.pending_review_items.clear(); | |
| 194 | 221 | self.analysis_errors.clear(); | |
| 222 | + | self.operation_progress = Some(crate::state::OperationProgress::new()); | |
| 195 | 223 | self.import_mode = ImportMode::Analyzing { | |
| 196 | 224 | completed: 0, | |
| 197 | 225 | total, | |
| 198 | 226 | current_name: String::new(), | |
| 199 | 227 | }; | |
| 228 | + | self.status = format!("Analyzing {total} samples..."); | |
| 200 | 229 | ||
| 201 | 230 | let _ = self.backend.start_analysis(sample_hashes, config); | |
| 202 | 231 | } | |
| 203 | 232 | ||
| 204 | - | /// Cancel the running analysis batch and return to idle state. | |
| 233 | + | /// Cancel the running analysis batch and land in the acknowledgement | |
| 234 | + | /// screen (C-3) so the user sees what was analysed vs what was discarded. | |
| 235 | + | /// Falls through to `None` only when there's no meaningful progress to | |
| 236 | + | /// acknowledge (cancel before any work happened). | |
| 205 | 237 | pub fn cancel_analysis(&mut self) { | |
| 238 | + | let progress = match &self.import_mode { | |
| 239 | + | ImportMode::Analyzing { completed, total, .. } => Some((*completed, *total)), | |
| 240 | + | _ => None, | |
| 241 | + | }; | |
| 206 | 242 | let _ = self.backend.cancel_analysis(); | |
| 207 | - | self.import_mode = ImportMode::None; | |
| 208 | 243 | self.pending_review_items.clear(); | |
| 209 | 244 | self.status = "Analysis cancelled".to_string(); | |
| 245 | + | self.import_mode = match progress { | |
| 246 | + | Some((completed, total)) if total > 0 => ImportMode::OperationCancelled { | |
| 247 | + | kind: crate::state::CancelKind::Analysis, | |
| 248 | + | completed, | |
| 249 | + | total, | |
| 250 | + | destination: None, | |
| 251 | + | }, | |
| 252 | + | _ => ImportMode::None, | |
| 253 | + | }; | |
| 210 | 254 | } | |
| 211 | 255 | ||
| 212 | 256 | // --- Folder import --- | |
| 213 | 257 | ||
| 214 | 258 | /// Quick import: choose folder → import as new vault → analyze with defaults. | |
| 215 | 259 | /// Skips ConfigureImport, TagFolders, and ConfigureAnalysis screens. | |
| 260 | + | /// | |
| 261 | + | /// Large folders (≥ 100 files OR ≥ 1 GiB) route through a preflight | |
| 262 | + | /// confirmation modal so accidental imports of huge directories | |
| 263 | + | /// (Downloads, Music libraries, root folders) don't commit before the | |
| 264 | + | /// user has seen what they're about to do. Small folders import | |
| 265 | + | /// immediately to keep the common path frictionless. | |
| 216 | 266 | pub fn quick_import_folder(&mut self, source: PathBuf) { | |
| 267 | + | let (file_count, total_bytes) = walk_folder_stats(&source); | |
| 268 | + | // M-9: skip preflight entirely when the user has opted out persistently. | |
| 269 | + | let should_preflight = !self.import_preflight_disabled | |
| 270 | + | && (file_count >= QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD | |
| 271 | + | || total_bytes >= QUICK_IMPORT_PREFLIGHT_BYTE_THRESHOLD); | |
| 272 | + | if should_preflight { | |
| 273 | + | self.pending_import_preflight = Some(ImportPreflight { | |
| 274 | + | source, | |
| 275 | + | file_count, | |
| 276 | + | total_bytes, | |
| 277 | + | }); | |
| 278 | + | } else { | |
| 279 | + | self.start_quick_import_now(source); | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// Bypass the preflight and start the Quick-Import. Called both directly | |
| 284 | + | /// (small folders) and from the preflight modal's Continue button. | |
| 285 | + | pub fn start_quick_import_now(&mut self, source: PathBuf) { | |
| 217 | 286 | let vfs_name = source | |
| 218 | 287 | .file_name() | |
| 219 | 288 | .and_then(|n| n.to_str()) | |
| @@ -224,6 +293,18 @@ impl BrowserState { | |||
| 224 | 293 | self.start_folder_import(source, strategy); | |
| 225 | 294 | } | |
| 226 | 295 | ||
| 296 | + | /// Accept the pending preflight and start the import. | |
| 297 | + | pub fn accept_import_preflight(&mut self) { | |
| 298 | + | if let Some(p) = self.pending_import_preflight.take() { | |
| 299 | + | self.start_quick_import_now(p.source); | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /// Discard the pending preflight without starting an import. | |
| 304 | + | pub fn cancel_import_preflight(&mut self) { | |
| 305 | + | self.pending_import_preflight = None; | |
| 306 | + | } | |
| 307 | + | ||
| 227 | 308 | /// Open the import configuration modal for a dropped or selected folder. | |
| 228 | 309 | pub fn show_import_options(&mut self, source: PathBuf) { | |
| 229 | 310 | let source_name = source | |
| @@ -252,6 +333,30 @@ impl BrowserState { | |||
| 252 | 333 | }; | |
| 253 | 334 | } | |
| 254 | 335 | ||
| 336 | + | /// Replace the source folder on the ConfigureImport screen with a new | |
| 337 | + | /// one. Re-runs the dry-run audio-file count and updates `source_name`, | |
| 338 | + | /// but preserves the user's strategy choice and any custom vault name | |
| 339 | + | /// they've already typed (M-5). | |
| 340 | + | pub fn change_import_source(&mut self, new_source: PathBuf) { | |
| 341 | + | let new_name = new_source | |
| 342 | + | .file_name() | |
| 343 | + | .and_then(|n| n.to_str()) | |
| 344 | + | .unwrap_or("folder") | |
| 345 | + | .to_string(); | |
| 346 | + | let new_count = count_audio_files(&new_source); | |
| 347 | + | if let ImportMode::ConfigureImport { | |
| 348 | + | source, | |
| 349 | + | source_name, | |
| 350 | + | audio_file_count, | |
| 351 | + | .. | |
| 352 | + | } = &mut self.import_mode | |
| 353 | + | { | |
| 354 | + | *source = new_source; | |
| 355 | + | *source_name = new_name; | |
| 356 | + | *audio_file_count = new_count; | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 255 | 360 | /// Spawn the background import worker to walk and import a folder. | |
| 256 | 361 | pub fn start_folder_import(&mut self, source: PathBuf, strategy: ImportStrategy) { | |
| 257 | 362 | // Stash source path so the retry button can re-open the config screen. | |
| @@ -271,12 +376,14 @@ impl BrowserState { | |||
| 271 | 376 | ||
| 272 | 377 | self.import_file_errors.clear(); | |
| 273 | 378 | self.analysis_errors.clear(); | |
| 379 | + | self.operation_progress = Some(crate::state::OperationProgress::new()); | |
| 274 | 380 | let loose_files = self.settings.is_loose_files; | |
| 275 | 381 | self.import_mode = ImportMode::Importing { | |
| 276 | 382 | total: 0, | |
| 277 | 383 | completed: 0, | |
| 278 | 384 | current_name: String::new(), | |
| 279 | 385 | walking: true, | |
| 386 | + | walking_count: 0, | |
| 280 | 387 | total_bytes: 0, | |
| 281 | 388 | loose_files, | |
| 282 | 389 | }; | |
| @@ -295,6 +402,18 @@ impl BrowserState { | |||
| 295 | 402 | for event in events { | |
| 296 | 403 | match event { | |
| 297 | 404 | // --- Import events --- | |
| 405 | + | BackendEvent::ImportWalkProgress { count, total_bytes } => { | |
| 406 | + | if let ImportMode::Importing { | |
| 407 | + | walking: true, | |
| 408 | + | walking_count, | |
| 409 | + | total_bytes: bytes_slot, | |
| 410 | + | .. | |
| 411 | + | } = &mut self.import_mode | |
| 412 | + | { | |
| 413 | + | *walking_count = count; | |
| 414 | + | *bytes_slot = total_bytes; | |
| 415 | + | } | |
| 416 | + | } | |
| 298 | 417 | BackendEvent::ImportWalkComplete { total, total_bytes } => { | |
| 299 | 418 | let loose_files = matches!( | |
| 300 | 419 | &self.import_mode, | |
| @@ -305,6 +424,7 @@ impl BrowserState { | |||
| 305 | 424 | completed: 0, | |
| 306 | 425 | current_name: String::new(), | |
| 307 | 426 | walking: false, | |
| 427 | + | walking_count: 0, | |
| 308 | 428 | total_bytes, | |
| 309 | 429 | loose_files, | |
| 310 | 430 | }; | |
| @@ -323,6 +443,7 @@ impl BrowserState { | |||
| 323 | 443 | completed, | |
| 324 | 444 | current_name, | |
| 325 | 445 | walking: false, | |
| 446 | + | walking_count: 0, | |
| 326 | 447 | total_bytes: prev_bytes, | |
| 327 | 448 | loose_files, | |
| 328 | 449 | }; | |
| @@ -343,6 +464,11 @@ impl BrowserState { | |||
| 343 | 464 | })); | |
| 344 | 465 | self.refresh_contents(); | |
| 345 | 466 | self.refresh_all_tags(); | |
| 467 | + | // First successful import auto-dismisses the welcome hint — | |
| 468 | + | // the user has moved past onboarding. | |
| 469 | + | if !imported.is_empty() && self.show_first_launch_hint { | |
| 470 | + | self.dismiss_first_launch_hint(); | |
| 471 | + | } | |
| 346 | 472 | ||
| 347 | 473 | let mut parts = vec![format!("Imported {} files", imported.len())]; | |
| 348 | 474 | if duplicates > 0 { | |
| @@ -427,6 +553,7 @@ impl BrowserState { | |||
| 427 | 553 | } | |
| 428 | 554 | BackendEvent::AnalysisBatchComplete => { | |
| 429 | 555 | let items = std::mem::take(&mut self.pending_review_items); | |
| 556 | + | let error_count = self.analysis_errors.len(); | |
| 430 | 557 | if self.quick_import { | |
| 431 | 558 | // Auto-accept all suggestions in quick mode | |
| 432 | 559 | for item in &items { | |
| @@ -439,7 +566,11 @@ impl BrowserState { | |||
| 439 | 566 | self.refresh_vfs_list(); | |
| 440 | 567 | let count = items.len(); | |
| 441 | 568 | self.import_mode = ImportMode::None; | |
| 442 | - | self.status = format!("Quick import complete: {count} samples analyzed"); | |
| 569 | + | self.status = if error_count == 0 { | |
| 570 | + | format!("Quick import complete: {count} samples analyzed") | |
| 571 | + | } else { | |
| 572 | + | format!("Quick import complete: {count} samples analyzed ({error_count} errors)") | |
| 573 | + | }; | |
| 443 | 574 | } else if items.is_empty() { | |
| 444 | 575 | if self.has_import_errors() { | |
| 445 | 576 | self.import_mode = ImportMode::ReviewErrors; | |
| @@ -448,10 +579,16 @@ impl BrowserState { | |||
| 448 | 579 | self.status = "Analysis complete, no results".to_string(); | |
| 449 | 580 | } | |
| 450 | 581 | } else { | |
| 582 | + | let count = items.len(); | |
| 451 | 583 | self.import_mode = ImportMode::ReviewSuggestions { | |
| 452 | 584 | items, | |
| 453 | 585 | current_idx: 0, | |
| 454 | 586 | }; | |
| 587 | + | self.status = if error_count == 0 { | |
| 588 | + | format!("Analyzed {count} samples") | |
| 589 | + | } else { | |
| 590 | + | format!("Analyzed {count} samples ({error_count} errors)") | |
| 591 | + | }; | |
| 455 | 592 | } | |
| 456 | 593 | } | |
| 457 | 594 | ||
| @@ -528,12 +665,29 @@ impl BrowserState { | |||
| 528 | 665 | true | |
| 529 | 666 | } | |
| 530 | 667 | ||
| 531 | - | /// Cancel the running folder import and return to idle state. | |
| 668 | + | /// Cancel the running folder import. Lands in the acknowledgement screen | |
| 669 | + | /// (C-3) when there's meaningful progress to acknowledge (post-walk import | |
| 670 | + | /// of N/M files); falls through to `None` when no files have committed yet | |
| 671 | + | /// (walking phase, or cancel before start). | |
| 532 | 672 | pub fn cancel_import(&mut self) { | |
| 673 | + | let progress = match &self.import_mode { | |
| 674 | + | ImportMode::Importing { | |
| 675 | + | completed, total, walking, .. | |
| 676 | + | } if !*walking && *total > 0 => Some((*completed, *total)), | |
| 677 | + | _ => None, | |
| 678 | + | }; | |
| 533 | 679 | let _ = self.backend.cancel_import(); | |
| 534 | - | self.import_mode = ImportMode::None; | |
| 535 | 680 | self.refresh_contents(); | |
| 536 | 681 | self.status = "Import cancelled".to_string(); | |
| 682 | + | self.import_mode = match progress { | |
| 683 | + | Some((completed, total)) => ImportMode::OperationCancelled { | |
| 684 | + | kind: crate::state::CancelKind::Import, | |
| 685 | + | completed, | |
| 686 | + | total, | |
| 687 | + | destination: None, | |
| 688 | + | }, | |
| 689 | + | None => ImportMode::None, | |
| 690 | + | }; | |
| 537 | 691 | } | |
| 538 | 692 | ||
| 539 | 693 | /// Cancel the current import and re-open the import configuration screen so | |
| @@ -558,12 +712,17 @@ impl BrowserState { | |||
| 558 | 712 | ||
| 559 | 713 | /// Apply user-entered tags to each imported folder's samples, then start analysis. | |
| 560 | 714 | pub fn apply_folder_tags(&mut self) { | |
| 715 | + | self.tag_folders_apply_all_input.clear(); | |
| 561 | 716 | let mode = std::mem::replace(&mut self.import_mode, ImportMode::None); | |
| 562 | 717 | if let ImportMode::TagFolders { | |
| 563 | 718 | entries, | |
| 564 | 719 | sample_hashes, | |
| 565 | 720 | } = mode | |
| 566 | 721 | { | |
| 722 | + | // Stash entries + hashes so the Back button on ConfigureAnalysis | |
| 723 | + | // (C-1) can rehydrate this screen. tag_input strings are preserved | |
| 724 | + | // verbatim — re-applying via add_tag is safe (INSERT OR IGNORE). | |
| 725 | + | self.last_folder_tags = Some((entries.clone(), sample_hashes.clone())); | |
| 567 | 726 | let mut applied = 0usize; | |
| 568 | 727 | for entry in &entries { | |
| 569 | 728 | if entry.tag_input.trim().is_empty() { | |
| @@ -595,14 +754,29 @@ impl BrowserState { | |||
| 595 | 754 | ||
| 596 | 755 | /// Skip folder tagging and proceed directly to analysis. | |
| 597 | 756 | pub fn skip_folder_tags(&mut self) { | |
| 757 | + | self.tag_folders_apply_all_input.clear(); | |
| 598 | 758 | let mode = std::mem::replace(&mut self.import_mode, ImportMode::None); | |
| 599 | - | if let ImportMode::TagFolders { sample_hashes, .. } = mode { | |
| 759 | + | if let ImportMode::TagFolders { entries, sample_hashes } = mode { | |
| 760 | + | // Stash entries so Back from ConfigureAnalysis (C-1) can return the | |
| 761 | + | // user to the tagging screen even when they skipped initially. | |
| 762 | + | self.last_folder_tags = Some((entries.clone(), sample_hashes.clone())); | |
| 600 | 763 | self.start_analysis_flow(sample_hashes); | |
| 601 | 764 | } else { | |
| 602 | 765 | self.import_mode = mode; | |
| 603 | 766 | } | |
| 604 | 767 | } | |
| 605 | 768 | ||
| 769 | + | /// Restore the most recently-shown TagFolders screen from the stash. Called | |
| 770 | + | /// from the Back button on ConfigureAnalysis. No-op if nothing's stashed. | |
| 771 | + | pub fn back_to_tag_folders(&mut self) { | |
| 772 | + | if let Some((entries, sample_hashes)) = self.last_folder_tags.take() { | |
| 773 | + | self.import_mode = ImportMode::TagFolders { | |
| 774 | + | entries, | |
| 775 | + | sample_hashes, | |
| 776 | + | }; | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | ||
| 606 | 780 | /// Commit accepted tag suggestions from analysis review, then return to idle. | |
| 607 | 781 | pub fn apply_accepted_suggestions(&mut self) { | |
| 608 | 782 | if let ImportMode::ReviewSuggestions { ref items, .. } = self.import_mode { | |
| @@ -770,6 +944,12 @@ impl BrowserState { | |||
| 770 | 944 | ||
| 771 | 945 | /// Spawn the export worker and start processing. | |
| 772 | 946 | pub fn run_export(&mut self, items: Vec<audiofiles_core::export::ExportItem>, config: audiofiles_core::export::ExportConfig) { | |
| 947 | + | // Stash destination so the cancel-acknowledgement screen can name it | |
| 948 | + | // if the user cancels mid-export (C-3). The Exporting variant doesn't | |
| 949 | + | // carry destination — it's consumed by the worker — but the path is | |
| 950 | + | // still useful in the UI for "files already written remain at <path>". | |
| 951 | + | self.last_export_destination = Some(config.destination.clone()); | |
| 952 | + | self.operation_progress = Some(crate::state::OperationProgress::new()); | |
| 773 | 953 | let _ = self.backend.start_export(items, config); | |
| 774 | 954 | ||
| 775 | 955 | self.import_mode = ImportMode::Exporting { | |
| @@ -787,11 +967,28 @@ impl BrowserState { | |||
| 787 | 967 | self.status = "Cleanup cancelled".to_string(); | |
| 788 | 968 | } | |
| 789 | 969 | ||
| 790 | - | /// Cancel the running export and return to idle state. | |
| 970 | + | /// Cancel the running export. Lands in the acknowledgement screen (C-3) | |
| 971 | + | /// when meaningful progress has happened, surfacing the destination folder | |
| 972 | + | /// so the user knows where partial files may sit. Falls through to `None` | |
| 973 | + | /// when no items have been written yet. | |
| 791 | 974 | pub fn cancel_export(&mut self) { | |
| 975 | + | let progress = match &self.import_mode { | |
| 976 | + | ImportMode::Exporting { completed, total, .. } if *total > 0 => { | |
| 977 | + | Some((*completed, *total)) | |
| 978 | + | } | |
| 979 | + | _ => None, | |
| 980 | + | }; | |
| 792 | 981 | let _ = self.backend.cancel_export(); | |
| 793 | - | self.import_mode = ImportMode::None; | |
| 794 | 982 | self.status = "Export cancelled".to_string(); | |
| 983 | + | self.import_mode = match progress { | |
| 984 | + | Some((completed, total)) => ImportMode::OperationCancelled { | |
| 985 | + | kind: crate::state::CancelKind::Export, | |
| 986 | + | completed, | |
| 987 | + | total, | |
| 988 | + | destination: self.last_export_destination.clone(), | |
| 989 | + | }, | |
| 990 | + | None => ImportMode::None, | |
| 991 | + | }; | |
| 795 | 992 | } | |
| 796 | 993 | ||
| 797 | 994 | // --- Edit operations (floating editor window) --- | |
| @@ -832,6 +1029,27 @@ impl BrowserState { | |||
| 832 | 1029 | self.edit.hash = None; | |
| 833 | 1030 | } | |
| 834 | 1031 | ||
| 1032 | + | /// M-11: best-effort cancel of the in-flight edit. Signals the worker via | |
| 1033 | + | /// the backend and clears `in_progress` so the UI is interactive again. | |
| 1034 | + | /// The worker may still finish writing its output; if it does, the result | |
| 1035 | + | /// path will eventually be handled by `handle_edit_complete` as normal. | |
| 1036 | + | pub fn cancel_edit_operation(&mut self) { | |
| 1037 | + | let _ = self.backend.cancel_edit(); | |
| 1038 | + | self.edit.in_progress = false; | |
| 1039 | + | self.status = "Edit cancelled.".to_string(); | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | /// M-12: discard the pending edit result without applying it. The result | |
| 1043 | + | /// file held in `pending_result` is dropped; if the temp path still exists | |
| 1044 | + | /// on disk it is removed. | |
| 1045 | + | pub fn discard_edit_result(&mut self) { | |
| 1046 | + | if let Some(pending) = self.edit.pending_result.take() { | |
| 1047 | + | let _ = std::fs::remove_file(&pending.result_path); | |
| 1048 | + | } | |
| 1049 | + | self.edit.result_prompt = false; | |
| 1050 | + | self.status = "Edit result discarded.".to_string(); | |
| 1051 | + | } | |
| 1052 | + | ||
| 835 | 1053 | /// Apply trim to the current edit target. | |
| 836 | 1054 | pub fn apply_edit_trim(&mut self) { | |
| 837 | 1055 | let hash = match &self.edit.hash { |
| @@ -74,12 +74,89 @@ impl BrowserState { | |||
| 74 | 74 | self.show_loose_files_warning = false; | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | /// Locate missing loose-files mode samples by walking `search_root` and | |
| 78 | + | /// hash-verifying candidates. The dialog stays open with an updated | |
| 79 | + | /// `loose_files_missing_count` so the user can run Locate again against a | |
| 80 | + | /// different directory if some samples are still missing. | |
| 81 | + | pub fn locate_missing_loose_files(&mut self, search_root: std::path::PathBuf) { | |
| 82 | + | match self.backend.relocate_missing_loose_files(&search_root) { | |
| 83 | + | Ok((relocated, still_missing)) => { | |
| 84 | + | self.loose_files_missing_count = still_missing; | |
| 85 | + | self.status = if relocated == 0 { | |
| 86 | + | "No matching files found in that folder.".to_string() | |
| 87 | + | } else if still_missing == 0 { | |
| 88 | + | format!("Relocated {relocated} samples. All missing files found.") | |
| 89 | + | } else { | |
| 90 | + | format!( | |
| 91 | + | "Relocated {relocated} samples. {still_missing} still missing.", | |
| 92 | + | ) | |
| 93 | + | }; | |
| 94 | + | if still_missing == 0 { | |
| 95 | + | self.show_loose_files_warning = false; | |
| 96 | + | } | |
| 97 | + | self.refresh_contents(); | |
| 98 | + | } | |
| 99 | + | Err(e) => { | |
| 100 | + | self.status = format!("Locate failed: {e}"); | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 77 | 105 | /// Dismiss the first-launch hint and persist the preference. | |
| 78 | 106 | pub fn dismiss_first_launch_hint(&mut self) { | |
| 79 | 107 | self.show_first_launch_hint = false; | |
| 80 | 108 | let _ = self.backend.set_config("hints_dismissed", "1"); | |
| 81 | 109 | } | |
| 82 | 110 | ||
| 111 | + | /// Re-surface the welcome screen for a user who dismissed it. Persists the | |
| 112 | + | /// reset so the welcome renders again on next launch until re-dismissed. | |
| 113 | + | pub fn show_welcome(&mut self) { | |
| 114 | + | self.show_first_launch_hint = true; | |
| 115 | + | let _ = self.backend.set_config("hints_dismissed", "0"); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Dismiss the sync-intro banner and persist the preference. Called both | |
| 119 | + | /// from the banner's "Maybe later" button and (implicitly) when the user | |
| 120 | + | /// clicks "Set up sync" — the banner should not re-appear after either. | |
| 121 | + | pub fn dismiss_sync_intro(&mut self) { | |
| 122 | + | self.show_sync_intro = false; | |
| 123 | + | let _ = self.backend.set_config("sync_intro_dismissed", "1"); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Whether the user has work in progress that would be interrupted by a | |
| 127 | + | /// library switch. Used to gate the library picker with a confirm modal so | |
| 128 | + | /// an accidental click doesn't cancel an active import or bulk operation. | |
| 129 | + | pub fn has_in_flight_work(&self) -> bool { | |
| 130 | + | !matches!(self.import_mode, crate::state::ImportMode::None) | |
| 131 | + | || self.bulk_modal.is_some() | |
| 132 | + | || self.pending_import_preflight.is_some() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Re-surface the VFS first-run banner ("a vault is your sample collection…"). | |
| 136 | + | /// Used from Settings / Help to bring back onboarding context after dismissal. | |
| 137 | + | pub fn reset_vfs_explanation(&mut self) { | |
| 138 | + | self.show_vfs_banner = true; | |
| 139 | + | let _ = self.backend.set_config("vfs_explained", "0"); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Globally rename a tag: every sample that carries `old_tag` will instead | |
| 143 | + | /// carry `new_tag`. Used by the tag context menu in the sidebar. Refreshes | |
| 144 | + | /// the cached tag list and posts a status message with the affected count. | |
| 145 | + | pub fn rename_tag_globally(&mut self, old_tag: &str, new_tag: &str) { | |
| 146 | + | match self.backend.rename_tag_globally(old_tag, new_tag) { | |
| 147 | + | Ok(count) => { | |
| 148 | + | self.refresh_all_tags(); | |
| 149 | + | // Also retire the old name from any active filter so the user | |
| 150 | + | // isn't filtering on a tag that no longer exists. | |
| 151 | + | self.search_filter.required_tags.retain(|t| t != old_tag); | |
| 152 | + | self.apply_search(); | |
| 153 | + | self.status = format!("Renamed tag: {old_tag} → {new_tag} ({count} sample{})", | |
| 154 | + | if count == 1 { "" } else { "s" }); | |
| 155 | + | } | |
| 156 | + | Err(e) => self.status = format!("Rename failed: {e}"), | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 83 | 160 | /// Refresh the cached list of all tags. | |
| 84 | 161 | pub fn refresh_all_tags(&mut self) { | |
| 85 | 162 | self.all_tags = Arc::new(self.backend.list_all_tags().unwrap_or_else(|e| { | |
| @@ -107,6 +184,7 @@ impl BrowserState { | |||
| 107 | 184 | self.search_filter = filter.clone(); | |
| 108 | 185 | self.search_query = filter.text_query.clone(); | |
| 109 | 186 | self.similarity_search_hash = None; | |
| 187 | + | self.similarity_source_name = None; | |
| 110 | 188 | self.selection.clear(); | |
| 111 | 189 | self.refresh_contents(); | |
| 112 | 190 | } | |
| @@ -139,6 +217,7 @@ impl BrowserState { | |||
| 139 | 217 | self.contents = Arc::new(nodes); | |
| 140 | 218 | self.active_collection = Some(id); | |
| 141 | 219 | self.similarity_search_hash = None; | |
| 220 | + | self.similarity_source_name = None; | |
| 142 | 221 | self.selection.clear(); | |
| 143 | 222 | self.status = format!("{count} samples in collection"); | |
| 144 | 223 | } | |
| @@ -162,6 +241,7 @@ impl BrowserState { | |||
| 162 | 241 | let count = nodes.len(); | |
| 163 | 242 | self.contents = Arc::new(nodes); | |
| 164 | 243 | self.similarity_search_hash = Some(hash.to_string()); | |
| 244 | + | self.similarity_source_name = self.backend.sample_original_name(hash).ok(); | |
| 165 | 245 | self.selection.clear(); | |
| 166 | 246 | self.status = format!("Found {count} similar samples"); | |
| 167 | 247 | } | |
| @@ -182,6 +262,7 @@ impl BrowserState { | |||
| 182 | 262 | let count = nodes.len(); | |
| 183 | 263 | self.contents = Arc::new(nodes); | |
| 184 | 264 | self.similarity_search_hash = Some(hash.to_string()); | |
| 265 | + | self.similarity_source_name = self.backend.sample_original_name(hash).ok(); | |
| 185 | 266 | self.selection.clear(); | |
| 186 | 267 | self.status = format!("Found {count} near-duplicates"); | |
| 187 | 268 | } | |
| @@ -194,6 +275,8 @@ impl BrowserState { | |||
| 194 | 275 | /// Clear similarity search mode and return to normal browsing. | |
| 195 | 276 | pub fn clear_similarity_search(&mut self) { | |
| 196 | 277 | self.similarity_search_hash = None; | |
| 278 | + | self.similarity_source_name = None; | |
| 279 | + | self.similarity_source_name = None; | |
| 197 | 280 | self.refresh_contents(); | |
| 198 | 281 | } | |
| 199 | 282 | ||
| @@ -213,6 +296,96 @@ impl BrowserState { | |||
| 213 | 296 | let _ = self.backend.set_config("theme", &self.current_theme_id); | |
| 214 | 297 | } | |
| 215 | 298 | ||
| 299 | + | /// Reset column visibility, sort, and row density to defaults. The Settings | |
| 300 | + | /// → Appearance "Reset columns" button calls this — a column accidentally | |
| 301 | + | /// dragged to 5 px stays that wide forever in egui memory until the app | |
| 302 | + | /// restarts, so the tooltip notes that widths recover on next launch. | |
| 303 | + | pub fn reset_columns(&mut self) { | |
| 304 | + | self.column_config = ColumnConfig::default(); | |
| 305 | + | self.row_height = 24.0; | |
| 306 | + | self.sort_column = SortColumn::Name; | |
| 307 | + | self.sort_direction = SortDirection::Ascending; | |
| 308 | + | self.save_column_config(); | |
| 309 | + | let _ = self.backend.set_config("row_height", "24"); | |
| 310 | + | self.status = "Columns reset to defaults".to_string(); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// Invert the current selection across the visible rows, then strip the | |
| 314 | + | /// ".." parent entry (it's not a sample and must never participate in a | |
| 315 | + | /// bulk operation). Used by Cmd+Shift+I and the matching menu items. | |
| 316 | + | pub fn invert_selection(&mut self) { | |
| 317 | + | let len = self.visible_len(); | |
| 318 | + | self.selection.invert(len); | |
| 319 | + | if self.current_dir.is_some() { | |
| 320 | + | self.selection.selected.remove(&0); | |
| 321 | + | if self.selection.focus == 0 && len > 1 { | |
| 322 | + | self.selection.focus = 1; | |
| 323 | + | } | |
| 324 | + | if self.selection.anchor == 0 && len > 1 { | |
| 325 | + | self.selection.anchor = 1; | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | self.refresh_selected_tags(); | |
| 329 | + | self.refresh_selected_detail(); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Persist the per-classification dismissed-suggestion map. | |
| 333 | + | fn save_dismissed_suggestions(&self) { | |
| 334 | + | // unwrap is safe: HashMap<String, Vec<String>> serialises cleanly. | |
| 335 | + | let json = serde_json::to_string(&self.dismissed_suggestions).unwrap(); | |
| 336 | + | let _ = self.backend.set_config("suggestions.dismissed", &json); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | /// Mark a tag suggestion as rejected for the given classification so it | |
| 340 | + | /// stops appearing on every future sample of that class. | |
| 341 | + | pub fn dismiss_suggestion(&mut self, classification: &str, tag: &str) { | |
| 342 | + | let entry = self | |
| 343 | + | .dismissed_suggestions | |
| 344 | + | .entry(classification.to_string()) | |
| 345 | + | .or_default(); | |
| 346 | + | if !entry.iter().any(|t| t == tag) { | |
| 347 | + | entry.push(tag.to_string()); | |
| 348 | + | self.save_dismissed_suggestions(); | |
| 349 | + | // M-1: remember the most recent dismissal so the detail panel can | |
| 350 | + | // surface an inline Undo for ~5 seconds. Replaces the previous | |
| 351 | + | // last-dismissed marker; older dismissals are no longer reachable | |
| 352 | + | // via the inline affordance (they're still in `dismissed_suggestions` | |
| 353 | + | // and recoverable via Settings → Reset suggestions). | |
| 354 | + | self.last_dismissed_suggestion = Some(( | |
| 355 | + | classification.to_string(), | |
| 356 | + | tag.to_string(), | |
| 357 | + | std::time::Instant::now(), | |
| 358 | + | )); | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// Re-enable the most recently dismissed suggestion (M-1). Clears the | |
| 363 | + | /// inline-Undo marker on success or when the entry is gone (already cleared | |
| 364 | + | /// by Settings → Reset suggestions, for example). | |
| 365 | + | pub fn undo_last_dismissal(&mut self) { | |
| 366 | + | let Some((class, tag, _)) = self.last_dismissed_suggestion.take() else { | |
| 367 | + | return; | |
| 368 | + | }; | |
| 369 | + | if let Some(entry) = self.dismissed_suggestions.get_mut(&class) { | |
| 370 | + | entry.retain(|t| t != &tag); | |
| 371 | + | if entry.is_empty() { | |
| 372 | + | self.dismissed_suggestions.remove(&class); | |
| 373 | + | } | |
| 374 | + | self.save_dismissed_suggestions(); | |
| 375 | + | self.status = format!("Restored suggestion: {tag}"); | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | /// Clear every dismissed suggestion across all classifications. Surfaced | |
| 380 | + | /// from Settings → Reset suggestions so a user who has over-dismissed | |
| 381 | + | /// during exploration can start fresh. | |
| 382 | + | pub fn reset_dismissed_suggestions(&mut self) { | |
| 383 | + | let n: usize = self.dismissed_suggestions.values().map(|v| v.len()).sum(); | |
| 384 | + | self.dismissed_suggestions.clear(); | |
| 385 | + | self.save_dismissed_suggestions(); | |
| 386 | + | self.status = format!("Reset {n} dismissed suggestion{}", if n == 1 { "" } else { "s" }); | |
| 387 | + | } | |
| 388 | + | ||
| 216 | 389 | /// Save column config to the user_config table. | |
| 217 | 390 | pub fn save_column_config(&self) { | |
| 218 | 391 | // unwrap is safe: ColumnConfig contains only primitive fields (bools, enums) |
| @@ -32,7 +32,7 @@ use crate::instrument::InstrumentPlayback; | |||
| 32 | 32 | use crate::preview::PreviewPlayback; | |
| 33 | 33 | ||
| 34 | 34 | mod navigation; | |
| 35 | - | mod import_workflow; | |
| 35 | + | pub mod import_workflow; | |
| 36 | 36 | mod bulk_ops; | |
| 37 | 37 | mod library; | |
| 38 | 38 | mod playback; | |
| @@ -58,6 +58,11 @@ pub struct SharedState { | |||
| 58 | 58 | /// Generation counter for streaming decode threads. Each new decode increments | |
| 59 | 59 | /// the generation; the thread exits if its generation no longer matches. | |
| 60 | 60 | pub decode_generation: AtomicU64, | |
| 61 | + | /// Name of the cpal output device currently bound to the preview stream. | |
| 62 | + | /// Written once at startup from `audio::start_output_stream` and read by | |
| 63 | + | /// the footer to surface "Preview: <device>" for diagnostic visibility. | |
| 64 | + | /// `None` means no device is available (audio output failed to start). | |
| 65 | + | pub preview_device_name: Mutex<Option<String>>, | |
| 61 | 66 | } | |
| 62 | 67 | ||
| 63 | 68 | impl Default for SharedState { | |
| @@ -68,6 +73,7 @@ impl Default for SharedState { | |||
| 68 | 73 | device_sample_rate: AtomicU32::new(44100), | |
| 69 | 74 | midi_recent_notes: Mutex::new(Vec::new()), | |
| 70 | 75 | decode_generation: AtomicU64::new(0), | |
| 76 | + | preview_device_name: Mutex::new(None), | |
| 71 | 77 | } | |
| 72 | 78 | } | |
| 73 | 79 | } | |
| @@ -93,6 +99,11 @@ pub struct BrowserState { | |||
| 93 | 99 | pub selection: Selection, | |
| 94 | 100 | pub selected_tags: Arc<Vec<String>>, | |
| 95 | 101 | pub status: String, | |
| 102 | + | /// When the current `status` message was posted. Drives the footer's | |
| 103 | + | /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means | |
| 104 | + | /// the status was set without going through `post_status` (legacy direct | |
| 105 | + | /// assignment) — the footer treats first-seen-non-empty as freshly-set. | |
| 106 | + | pub status_set_at: Option<Instant>, | |
| 96 | 107 | ||
| 97 | 108 | // Detail panel | |
| 98 | 109 | pub selected_analysis: Option<AnalysisResult>, | |
| @@ -113,8 +124,17 @@ pub struct BrowserState { | |||
| 113 | 124 | // Dynamic collection (saved search) name input | |
| 114 | 125 | pub collection_filter_name_input: String, | |
| 115 | 126 | ||
| 127 | + | /// Free-form input bound to the filter panel's Tags section so users can | |
| 128 | + | /// add tag filters from inside the filter panel itself (M-5 closed the | |
| 129 | + | /// add/remove asymmetry — tag chips already had a remove X, but no entry). | |
| 130 | + | pub filter_tag_input: String, | |
| 131 | + | ||
| 116 | 132 | // Similarity search | |
| 117 | 133 | pub similarity_search_hash: Option<String>, | |
| 134 | + | /// Display name of the source sample for the active similarity / duplicate | |
| 135 | + | /// search. Cached so the breadcrumb can render "Similar to: <name>" without | |
| 136 | + | /// a backend lookup on every frame. | |
| 137 | + | pub similarity_source_name: Option<String>, | |
| 118 | 138 | ||
| 119 | 139 | // Tags cache | |
| 120 | 140 | pub all_tags: Arc<Vec<String>>, | |
| @@ -166,6 +186,19 @@ pub struct BrowserState { | |||
| 166 | 186 | pub import_mode: ImportMode, | |
| 167 | 187 | /// When true, the import flow skips ConfigureImport, TagFolders, and ConfigureAnalysis. | |
| 168 | 188 | pub quick_import: bool, | |
| 189 | + | /// Pending Quick-Import awaiting user confirmation. Set when the picked | |
| 190 | + | /// folder exceeds the preflight thresholds in `import_workflow.rs`. | |
| 191 | + | pub pending_import_preflight: Option<crate::state::import_workflow::ImportPreflight>, | |
| 192 | + | // M-9: persistent dismissal of the import preflight modal; loaded from | |
| 193 | + | // config in new() so the user's prior choice survives restart. | |
| 194 | + | pub import_preflight_disabled: bool, | |
| 195 | + | // M-9: transient checkbox state for the "Don't ask again" affordance. | |
| 196 | + | // Reset to false on every modal close path. | |
| 197 | + | pub preflight_dont_ask: bool, | |
| 198 | + | // M-2: search input on the Shortcuts help tab; filters the grid live. | |
| 199 | + | pub help_shortcut_search: String, | |
| 200 | + | // M-6: search input on the Bulk Move modal; filters the directory list. | |
| 201 | + | pub bulk_move_filter: String, | |
| 169 | 202 | pub pending_review_items: Vec<ReviewItem>, | |
| 170 | 203 | ||
| 171 | 204 | // Error accumulation for import/analysis workflows | |
| @@ -178,8 +211,41 @@ pub struct BrowserState { | |||
| 178 | 211 | // Retry state: last analysis parameters so the user can restart analysis. | |
| 179 | 212 | pub last_analysis_hashes: Vec<(String, String)>, | |
| 180 | 213 | pub last_analysis_config: Option<AnalysisConfig>, | |
| 214 | + | /// Destination of the in-flight export. Stashed when `run_export` spawns | |
| 215 | + | /// so the cancel-acknowledgement screen (C-3) can surface "files already | |
| 216 | + | /// written to <destination> remain". | |
| 217 | + | pub last_export_destination: Option<PathBuf>, | |
| 218 | + | /// Stashed folder-tag entries from the most recent TagFolders pass so the | |
| 219 | + | /// Back button on the ConfigureAnalysis screen can rehydrate the previous | |
| 220 | + | /// state (C-1). Tags themselves are `INSERT OR IGNORE` so re-applying after | |
| 221 | + | /// a Back is a no-op for the backend. | |
| 222 | + | pub last_folder_tags: Option<(Vec<crate::state::FolderTagEntry>, Vec<(String, String)>)>, | |
| 223 | + | /// Rolling progress samples for the current long-running operation | |
| 224 | + | /// (import / analysis / export). Drives the rate + ETA readout (M-11). | |
| 225 | + | /// Reset when an operation starts; consulted by the corresponding draw fn. | |
| 226 | + | pub operation_progress: Option<crate::state::OperationProgress>, | |
| 227 | + | /// Tag input on the Tag Folders screen's "Apply to all" row (M-9). | |
| 228 | + | /// Persists across frames so the user can type the value, then click the | |
| 229 | + | /// commit button. Reset when leaving the screen via Back / Skip / Apply. | |
| 230 | + | pub tag_folders_apply_all_input: String, | |
| 231 | + | /// Last backend error from a name-modal submit (vault create/rename, folder | |
| 232 | + | /// create/rename). Surfaces inline so the modal can stay open on failure | |
| 233 | + | /// rather than discarding the user's typed input (C-3). Cleared on modal | |
| 234 | + | /// open / successful submit / explicit Cancel. | |
| 235 | + | pub name_modal_error: Option<String>, | |
| 181 | 236 | /// Set by "/" keyboard shortcut to focus the search bar on the next frame. | |
| 182 | 237 | pub focus_search: bool, | |
| 238 | + | /// Set by Tab from the file table to focus the detail-panel tag input on the next frame. | |
| 239 | + | pub focus_tag_input: bool, | |
| 240 | + | /// Per-classification dismissed tag suggestions: e.g. dismissing | |
| 241 | + | /// "percussion" on a kick suppresses it on every future kick. Persisted | |
| 242 | + | /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map. | |
| 243 | + | pub dismissed_suggestions: std::collections::HashMap<String, Vec<String>>, | |
| 244 | + | /// Last suggestion that was dismissed plus when. Drives the inline Undo | |
| 245 | + | /// affordance in the detail panel (M-1) — visible for ~5 seconds after | |
| 246 | + | /// the dismiss, then fades. `None` means there's nothing to undo right | |
| 247 | + | /// now (initial state or after a successful undo / timeout). | |
| 248 | + | pub last_dismissed_suggestion: Option<(String, String, Instant)>, | |
| 183 | 249 | /// Set by keyboard navigation to scroll the file list to the focused row. | |
| 184 | 250 | pub scroll_to_row: Option<usize>, | |
| 185 | 251 | ||
| @@ -191,6 +257,14 @@ pub struct BrowserState { | |||
| 191 | 257 | pub active_collection: Option<CollectionId>, | |
| 192 | 258 | pub collection_create_input: String, | |
| 193 | 259 | pub collection_rename_target: Option<(CollectionId, String)>, | |
| 260 | + | /// Inline rename for a tag in the sidebar: `(old_tag, new_name_buffer)`. | |
| 261 | + | /// Submission calls `backend.rename_tag_globally` and refreshes the tag list. | |
| 262 | + | pub tag_rename_target: Option<(String, String)>, | |
| 263 | + | /// Cached preview for the active rename: `(affected_sample_count, descendant_tags)`. | |
| 264 | + | /// Computed when `tag_rename_target` is opened (M-12); cleared when modal closes. | |
| 265 | + | /// Descendants are listed so the user knows they will NOT be renamed (the | |
| 266 | + | /// backend's `rename_tag_globally` is exact-match-only). | |
| 267 | + | pub tag_rename_preview: Option<(usize, Vec<String>)>, | |
| 194 | 268 | pub show_collection_create: bool, | |
| 195 | 269 | ||
| 196 | 270 | // Edit — floating editor window | |
| @@ -203,6 +277,9 @@ pub struct BrowserState { | |||
| 203 | 277 | pub show_vfs_banner: bool, | |
| 204 | 278 | /// Show "Right-click for options · F1 for shortcuts" hint until dismissed. | |
| 205 | 279 | pub show_first_launch_hint: bool, | |
| 280 | + | /// Show the "set up cloud sync to back up your library" banner. Surfaces | |
| 281 | + | /// once after the first successful import; persisted via `sync_intro_dismissed`. | |
| 282 | + | pub show_sync_intro: bool, | |
| 206 | 283 | ||
| 207 | 284 | // Drag-out | |
| 208 | 285 | /// Set when an OS drag fires; prevents re-triggering until the pointer is | |
| @@ -286,6 +363,14 @@ impl BrowserState { | |||
| 286 | 363 | // First-run VFS banner | |
| 287 | 364 | let vfs_explained = backend.get_config("vfs_explained").ok().flatten().as_deref() == Some("1"); | |
| 288 | 365 | let hints_dismissed = backend.get_config("hints_dismissed").ok().flatten().as_deref() == Some("1"); | |
| 366 | + | let sync_intro_dismissed = backend.get_config("sync_intro_dismissed").ok().flatten().as_deref() == Some("1"); | |
| 367 | + | // M-9: load persistent preflight dismissal. | |
| 368 | + | let import_preflight_disabled = backend | |
| 369 | + | .get_config("import_preflight_disabled") | |
| 370 | + | .ok() | |
| 371 | + | .flatten() | |
| 372 | + | .as_deref() | |
| 373 | + | == Some("1"); | |
| 289 | 374 | ||
| 290 | 375 | // Load display density | |
| 291 | 376 | let row_height = backend.get_config("row_height").ok().flatten() | |
| @@ -293,6 +378,14 @@ impl BrowserState { | |||
| 293 | 378 | .unwrap_or(24.0) | |
| 294 | 379 | .clamp(20.0, 32.0); | |
| 295 | 380 | ||
| 381 | + | // Load dismissed tag suggestions | |
| 382 | + | let dismissed_suggestions: std::collections::HashMap<String, Vec<String>> = backend | |
| 383 | + | .get_config("suggestions.dismissed") | |
| 384 | + | .ok() | |
| 385 | + | .flatten() | |
| 386 | + | .and_then(|s| serde_json::from_str(&s).ok()) | |
| 387 | + | .unwrap_or_default(); | |
| 388 | + | ||
| 296 | 389 | // Load mirror settings | |
| 297 | 390 | let mirror_enabled = backend.get_config("mirror_enabled").ok().flatten().as_deref() == Some("1"); | |
| 298 | 391 | let mirror_path = backend | |
| @@ -317,6 +410,7 @@ impl BrowserState { | |||
| 317 | 410 | selection: Selection::new(), | |
| 318 | 411 | selected_tags: Arc::new(Vec::new()), | |
| 319 | 412 | status: String::new(), | |
| 413 | + | status_set_at: None, | |
| 320 | 414 | selected_analysis: None, | |
| 321 | 415 | selected_waveform: None, | |
| 322 | 416 | tag_input: String::new(), | |
| @@ -328,7 +422,9 @@ impl BrowserState { | |||
| 328 | 422 | search_filter: SearchFilter::default(), | |
| 329 | 423 | filter_panel_open: false, | |
| 330 | 424 | collection_filter_name_input: String::new(), | |
| 425 | + | filter_tag_input: String::new(), | |
| 331 | 426 | similarity_search_hash: None, | |
| 427 | + | similarity_source_name: None, | |
| 332 | 428 | all_tags: Arc::new(all_tags), | |
| 333 | 429 | tag_search: String::new(), | |
| 334 | 430 | previewing_hash: None, | |
| @@ -357,6 +453,14 @@ impl BrowserState { | |||
| 357 | 453 | column_config: ColumnConfig::default(), | |
| 358 | 454 | import_mode: ImportMode::None, | |
| 359 | 455 | quick_import: false, | |
| 456 | + | pending_import_preflight: None, | |
| 457 | + | // M-9. | |
| 458 | + | import_preflight_disabled, | |
| 459 | + | preflight_dont_ask: false, | |
| 460 | + | // M-2. | |
| 461 | + | help_shortcut_search: String::new(), | |
| 462 | + | // M-6. | |
| 463 | + | bulk_move_filter: String::new(), | |
| 360 | 464 | pending_review_items: Vec::new(), | |
| 361 | 465 | import_file_errors: Vec::new(), | |
| 362 | 466 | analysis_errors: Vec::new(), | |
| @@ -364,18 +468,30 @@ impl BrowserState { | |||
| 364 | 468 | last_import_source: None, | |
| 365 | 469 | last_analysis_hashes: Vec::new(), | |
| 366 | 470 | last_analysis_config: None, | |
| 471 | + | last_export_destination: None, | |
| 472 | + | last_folder_tags: None, | |
| 473 | + | operation_progress: None, | |
| 474 | + | tag_folders_apply_all_input: String::new(), | |
| 475 | + | name_modal_error: None, | |
| 367 | 476 | focus_search: false, | |
| 477 | + | focus_tag_input: false, | |
| 478 | + | dismissed_suggestions, | |
| 479 | + | last_dismissed_suggestion: None, | |
| 368 | 480 | scroll_to_row: None, | |
| 369 | 481 | current_theme_id: theme_id, | |
| 370 | 482 | collections: collections_list, | |
| 371 | 483 | active_collection: None, | |
| 372 | 484 | collection_create_input: String::new(), | |
| 373 | 485 | collection_rename_target: None, | |
| 486 | + | tag_rename_target: None, | |
| 487 | + | tag_rename_preview: None, | |
| 374 | 488 | show_collection_create: false, | |
| 375 | 489 | edit: EditUiState::default(), | |
| 376 | 490 | row_height, | |
| 377 | 491 | show_vfs_banner: !vfs_explained, | |
| 378 | 492 | show_first_launch_hint: !hints_dismissed, | |
| 493 | + | // Suppressed until the first import completes (see import_workflow.rs). | |
| 494 | + | show_sync_intro: !sync_intro_dismissed, | |
| 379 | 495 | os_drag_cooldown: None, | |
| 380 | 496 | mirror_enabled, | |
| 381 | 497 | mirror_path, | |
| @@ -386,4 +502,12 @@ impl BrowserState { | |||
| 386 | 502 | show_loose_files_warning: false, | |
| 387 | 503 | }) | |
| 388 | 504 | } | |
| 505 | + | ||
| 506 | + | /// Post a transient status message to the footer. Stamps `status_set_at` | |
| 507 | + | /// so the footer's time-fade (m-6) restarts. Prefer this over direct | |
| 508 | + | /// `self.status = ...` assignment so new posts reliably reset the fade. | |
| 509 | + | pub fn post_status(&mut self, msg: impl Into<String>) { | |
| 510 | + | self.status = msg.into(); | |
| 511 | + | self.status_set_at = Some(Instant::now()); | |
| 512 | + | } | |
| 389 | 513 | } |
| @@ -192,6 +192,7 @@ impl BrowserState { | |||
| 192 | 192 | /// Navigate into the selected directory, or go up if ".." is selected. | |
| 193 | 193 | pub fn enter_directory(&mut self) { | |
| 194 | 194 | self.similarity_search_hash = None; | |
| 195 | + | self.similarity_source_name = None; | |
| 195 | 196 | ||
| 196 | 197 | if self.has_parent_entry() && self.selection.focus == 0 { | |
| 197 | 198 | self.go_up(); | |
| @@ -215,6 +216,7 @@ impl BrowserState { | |||
| 215 | 216 | /// If a collection is active, exits collection view first. | |
| 216 | 217 | pub fn go_up(&mut self) { | |
| 217 | 218 | self.similarity_search_hash = None; | |
| 219 | + | self.similarity_source_name = None; | |
| 218 | 220 | if self.active_collection.is_some() { | |
| 219 | 221 | self.deactivate_collection(); | |
| 220 | 222 | return; | |
| @@ -247,6 +249,7 @@ impl BrowserState { | |||
| 247 | 249 | self.breadcrumb.clear(); | |
| 248 | 250 | self.selection.clear(); | |
| 249 | 251 | self.similarity_search_hash = None; | |
| 252 | + | self.similarity_source_name = None; | |
| 250 | 253 | self.refresh_contents(); | |
| 251 | 254 | self.refresh_collections(); | |
| 252 | 255 | self.status = format!("Switched to: {}", self.vfs_list[self.current_vfs_idx].name); |
| @@ -600,7 +600,7 @@ mod export_flow { | |||
| 600 | 600 | } | |
| 601 | 601 | ||
| 602 | 602 | #[test] | |
| 603 | - | fn cancel_export_resets_state() { | |
| 603 | + | fn cancel_export_lands_in_acknowledgement() { | |
| 604 | 604 | let (mut state, _dir) = make_state(); | |
| 605 | 605 | state.import_mode = ImportMode::Exporting { | |
| 606 | 606 | completed: 3, | |
| @@ -608,11 +608,33 @@ mod export_flow { | |||
| 608 | 608 | current_name: "test.wav".to_string(), | |
| 609 | 609 | }; | |
| 610 | 610 | state.cancel_export(); | |
| 611 | - | assert!(matches!(state.import_mode, ImportMode::None)); | |
| 611 | + | // C-3: cancel with meaningful progress lands in the acknowledgement | |
| 612 | + | // screen, not None, so the user sees what landed vs what was discarded. | |
| 613 | + | match state.import_mode { | |
| 614 | + | ImportMode::OperationCancelled { completed, total, .. } => { | |
| 615 | + | assert_eq!(completed, 3); | |
| 616 | + | assert_eq!(total, 10); | |
| 617 | + | } | |
| 618 | + | _ => panic!("Expected OperationCancelled, got {:?}", std::mem::discriminant(&state.import_mode)), | |
| 619 | + | } | |
| 612 | 620 | assert_eq!(state.status, "Export cancelled"); | |
| 613 | 621 | } | |
| 614 | 622 | ||
| 615 | 623 | #[test] | |
| 624 | + | fn cancel_export_with_zero_progress_returns_to_none() { | |
| 625 | + | let (mut state, _dir) = make_state(); | |
| 626 | + | // total == 0 means the export hasn't started — no meaningful progress | |
| 627 | + | // to acknowledge. Falls through to None. | |
| 628 | + | state.import_mode = ImportMode::Exporting { | |
| 629 | + | completed: 0, | |
| 630 | + | total: 0, | |
| 631 | + | current_name: String::new(), | |
| 632 | + | }; | |
| 633 | + | state.cancel_export(); | |
| 634 | + | assert!(matches!(state.import_mode, ImportMode::None)); | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | #[test] | |
| 616 | 638 | fn poll_workers_without_active_work_returns_false() { | |
| 617 | 639 | let (mut state, _dir) = make_state(); | |
| 618 | 640 | assert!(!state.poll_workers()); | |
| @@ -740,7 +762,14 @@ mod import_and_analysis { | |||
| 740 | 762 | }); | |
| 741 | 763 | ||
| 742 | 764 | state.cancel_analysis(); | |
| 743 | - | assert!(matches!(state.import_mode, ImportMode::None)); | |
| 765 | + | // C-3: progress > 0 → acknowledgement screen. | |
| 766 | + | match state.import_mode { | |
| 767 | + | ImportMode::OperationCancelled { completed, total, .. } => { | |
| 768 | + | assert_eq!(completed, 5); | |
| 769 | + | assert_eq!(total, 10); | |
| 770 | + | } | |
| 771 | + | _ => panic!("Expected OperationCancelled"), | |
| 772 | + | } | |
| 744 | 773 | assert!(state.pending_review_items.is_empty()); | |
| 745 | 774 | assert_eq!(state.status, "Analysis cancelled"); | |
| 746 | 775 | } | |
| @@ -767,22 +796,48 @@ mod import_and_analysis { | |||
| 767 | 796 | } | |
| 768 | 797 | ||
| 769 | 798 | #[test] | |
| 770 | - | fn cancel_import_resets_state() { | |
| 799 | + | fn cancel_import_lands_in_acknowledgement() { | |
| 771 | 800 | let (mut state, _dir) = make_state(); | |
| 772 | 801 | state.import_mode = ImportMode::Importing { | |
| 773 | 802 | total: 10, | |
| 774 | 803 | completed: 3, | |
| 775 | 804 | current_name: "file.wav".to_string(), | |
| 776 | 805 | walking: false, | |
| 806 | + | walking_count: 0, | |
| 777 | 807 | total_bytes: 0, | |
| 778 | 808 | loose_files: false, | |
| 779 | 809 | }; | |
| 780 | 810 | state.cancel_import(); | |
| 781 | - | assert!(matches!(state.import_mode, ImportMode::None)); | |
| 811 | + | // C-3: cancel during real progress lands in the acknowledgement screen. | |
| 812 | + | match state.import_mode { | |
| 813 | + | ImportMode::OperationCancelled { completed, total, .. } => { | |
| 814 | + | assert_eq!(completed, 3); | |
| 815 | + | assert_eq!(total, 10); | |
| 816 | + | } | |
| 817 | + | _ => panic!("Expected OperationCancelled"), | |
| 818 | + | } | |
| 782 | 819 | assert_eq!(state.status, "Import cancelled"); | |
| 783 | 820 | } | |
| 784 | 821 | ||
| 785 | 822 | #[test] | |
| 823 | + | fn cancel_import_during_walking_returns_to_none() { | |
| 824 | + | let (mut state, _dir) = make_state(); | |
| 825 | + | // walking == true means no files have committed yet; falls through to | |
| 826 | + | // None so the user isn't shown a "Stopped at 0 of 0" screen. | |
| 827 | + | state.import_mode = ImportMode::Importing { | |
| 828 | + | total: 0, | |
| 829 | + | completed: 0, | |
| 830 | + | current_name: String::new(), | |
| 831 | + | walking: true, | |
| 832 | + | walking_count: 0, | |
| 833 | + | total_bytes: 0, | |
| 834 | + | loose_files: false, | |
| 835 | + | }; | |
| 836 | + | state.cancel_import(); | |
| 837 | + | assert!(matches!(state.import_mode, ImportMode::None)); | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | #[test] | |
| 786 | 841 | fn retry_import_reopens_config() { | |
| 787 | 842 | let (mut state, _dir) = make_state(); | |
| 788 | 843 | let source = std::env::temp_dir().join("retry_test"); | |
| @@ -792,6 +847,7 @@ mod import_and_analysis { | |||
| 792 | 847 | completed: 3, | |
| 793 | 848 | current_name: "file.wav".to_string(), | |
| 794 | 849 | walking: false, | |
| 850 | + | walking_count: 0, | |
| 795 | 851 | total_bytes: 0, | |
| 796 | 852 | loose_files: false, | |
| 797 | 853 | }; | |
| @@ -808,12 +864,15 @@ mod import_and_analysis { | |||
| 808 | 864 | completed: 3, | |
| 809 | 865 | current_name: "file.wav".to_string(), | |
| 810 | 866 | walking: false, | |
| 867 | + | walking_count: 0, | |
| 811 | 868 | total_bytes: 0, | |
| 812 | 869 | loose_files: false, | |
| 813 | 870 | }; | |
| 814 | 871 | state.retry_import(); | |
| 815 | - | // With no last_import_source, it cancels but cannot reopen config | |
| 816 | - | assert!(matches!(state.import_mode, ImportMode::None)); | |
| 872 | + | // With no last_import_source, retry calls cancel_import which now lands | |
| 873 | + | // in OperationCancelled (C-3). The retry path can't reopen config, so | |
| 874 | + | // the user is left on the acknowledgement screen. | |
| 875 | + | assert!(matches!(state.import_mode, ImportMode::OperationCancelled { .. })); | |
| 817 | 876 | } | |
| 818 | 877 | ||
| 819 | 878 | #[test] |
| @@ -31,6 +31,40 @@ pub enum ConfirmAction { | |||
| 31 | 31 | DeleteNode { node_id: NodeId, node_name: String }, | |
| 32 | 32 | DeleteVfs { vfs_id: VfsId, vfs_name: String }, | |
| 33 | 33 | DeleteMultiple { node_ids: Vec<NodeId>, count: usize }, | |
| 34 | + | DeleteCollection { coll_id: audiofiles_core::CollectionId, coll_name: String }, | |
| 35 | + | /// Remove a tag from every sample that carries it. | |
| 36 | + | RemoveTagGlobally { tag: String }, | |
| 37 | + | /// Switch to a different library while in-flight work would be interrupted. | |
| 38 | + | /// Non-destructive: confirm label is "Switch", no danger styling. | |
| 39 | + | SwitchLibrary { path: PathBuf, library_name: String }, | |
| 40 | + | /// Re-analyze selected samples when one or more already has computed values | |
| 41 | + | /// (BPM / Key / classification). Confirming opens the ConfigureAnalysis | |
| 42 | + | /// wizard; cancelling drops the operation. | |
| 43 | + | ReanalyzeOverwrite { | |
| 44 | + | sample_hashes: Vec<(String, String)>, | |
| 45 | + | overwrite_count: usize, | |
| 46 | + | }, | |
| 47 | + | /// Disconnect from cloud sync. Destructive because reconnecting requires | |
| 48 | + | /// re-entering the encryption password — a typo there would leave the cloud | |
| 49 | + | /// blob unreadable. `pending_changes` is surfaced in the detail line so the | |
| 50 | + | /// user knows whether unsynced work is at stake. | |
| 51 | + | DisconnectSync { pending_changes: i64 }, | |
| 52 | + | /// Permanently remove analysis-failed samples from the content store. The | |
| 53 | + | /// post-import error review surfaces these with per-row and bulk delete | |
| 54 | + | /// buttons; both gate through this variant so a stray click can't purge | |
| 55 | + | /// recoverable files (codec missing, transient read error, etc.). | |
| 56 | + | /// `single_index` distinguishes the per-row case (specific name in `name`) | |
| 57 | + | /// from the bulk "Remove All Failed" case (None). | |
| 58 | + | RemoveFailedSamples { | |
| 59 | + | single_index: Option<usize>, | |
| 60 | + | count: usize, | |
| 61 | + | name: Option<String>, | |
| 62 | + | }, | |
| 63 | + | /// Batch Reverse on a large selection. Single-sample Reverse is its own | |
| 64 | + | /// undo (click again); batch Reverse on N samples is easy to fire by | |
| 65 | + | /// accident and tedious to walk back. Gated at the call site when the | |
| 66 | + | /// selection size exceeds the threshold (10). | |
| 67 | + | ReverseSamples { count: usize }, | |
| 34 | 68 | } | |
| 35 | 69 | ||
| 36 | 70 | /// An undoable bulk operation. | |
| @@ -53,6 +87,12 @@ pub enum UndoOp { | |||
| 53 | 87 | tag: String, | |
| 54 | 88 | hashes: Vec<String>, | |
| 55 | 89 | }, | |
| 90 | + | /// Single-sample inline tag removal (from the detail panel's tag chip "x"). | |
| 91 | + | /// Restoring re-adds the tag to the same sample. | |
| 92 | + | TagRemove { | |
| 93 | + | hash: String, | |
| 94 | + | tag: String, | |
| 95 | + | }, | |
| 56 | 96 | } | |
| 57 | 97 | ||
| 58 | 98 | /// Active bulk operation modal. | |
| @@ -167,6 +207,8 @@ pub enum VaultAction { | |||
| 167 | 207 | RemoveVault(PathBuf), | |
| 168 | 208 | /// Rename a vault in the registry. | |
| 169 | 209 | RenameVault { path: PathBuf, new_name: String }, | |
| 210 | + | /// Repoint an offline vault's registry entry to a new directory. | |
| 211 | + | RelocateVault { old_path: PathBuf, new_path: PathBuf }, | |
| 170 | 212 | /// Scan storage stats for the active vault. | |
| 171 | 213 | ScanStorage, | |
| 172 | 214 | /// Deactivate the license key and return to activation screen. | |
| @@ -199,6 +241,9 @@ pub struct SettingsUiState { | |||
| 199 | 241 | ||
| 200 | 242 | /// Cached storage statistics from the last scan. | |
| 201 | 243 | pub storage_cache: Option<crate::backend::StorageStats>, | |
| 244 | + | /// Unix timestamp (seconds) of the last successful storage scan, used to | |
| 245 | + | /// render a "last scanned N minutes ago" label so stale numbers are visible. | |
| 246 | + | pub storage_cache_at: Option<i64>, | |
| 202 | 247 | /// Whether a storage scan is in progress. | |
| 203 | 248 | pub storage_scanning: bool, | |
| 204 | 249 | /// Masked license key for display. | |
| @@ -214,6 +259,11 @@ pub struct SyncUiState { | |||
| 214 | 259 | /// Whether the sync panel overlay is open. | |
| 215 | 260 | pub show_panel: bool, | |
| 216 | 261 | pub encryption_input: String, | |
| 262 | + | /// Confirm-password field, only used during first-time encryption setup | |
| 263 | + | /// (`!has_server_key`). Gates the Set Password button until it matches | |
| 264 | + | /// `encryption_input` — there is no recovery path if the user mistypes the | |
| 265 | + | /// password they're about to lock their cloud blob under. | |
| 266 | + | pub encryption_confirm_input: String, | |
| 217 | 267 | pub auth_code_input: String, | |
| 218 | 268 | /// API key input for initial setup. | |
| 219 | 269 | pub api_key_input: String, | |
| @@ -223,8 +273,33 @@ pub struct SyncUiState { | |||
| 223 | 273 | pub pending_action: Option<SyncSetupAction>, | |
| 224 | 274 | /// Whether a subscription fetch is in progress. | |
| 225 | 275 | pub subscription_loading: bool, | |
| 276 | + | /// When the in-flight subscription fetch was kicked off. Used to time the | |
| 277 | + | /// spinner out if the request never resolves — without this, a network | |
| 278 | + | /// failure leaves the panel pretending to be busy forever. | |
| 279 | + | pub subscription_loading_at: Option<std::time::Instant>, | |
| 226 | 280 | /// Whether a checkout request is in progress. | |
| 227 | 281 | pub checkout_loading: bool, | |
| 282 | + | /// When the in-flight checkout was kicked off. Same role as | |
| 283 | + | /// `subscription_loading_at` — a closed browser tab or declined card would | |
| 284 | + | /// otherwise leave every Subscribe / Change-tier button disabled forever. | |
| 285 | + | pub checkout_loading_at: Option<std::time::Instant>, | |
| 286 | + | /// Set true by `execute_confirmed_action` when the user confirms a | |
| 287 | + | /// DisconnectSync. The sync panel consumes the flag next frame and calls | |
| 288 | + | /// `sync.disconnect()`. Decouples the confirm dispatch (which lives in | |
| 289 | + | /// `bulk_ops.rs` and has no SyncManager handle) from the sync action. | |
| 290 | + | pub pending_disconnect: bool, | |
| 291 | + | /// Last URL returned by `sync.start_auth()`, cached so the Authenticating | |
| 292 | + | /// screen can offer a Copy URL fallback when the user's browser didn't open. | |
| 293 | + | pub auth_url: Option<String>, | |
| 294 | + | /// Per-VFS storage stats cache: `(sample_count, total_bytes)` keyed by the | |
| 295 | + | /// raw VfsId. Populated when the sync panel opens (on the assumption that | |
| 296 | + | /// vault contents don't change while the panel is showing) and rendered as | |
| 297 | + | /// a muted sub-label beside each Sync-audio-files checkbox. | |
| 298 | + | pub vfs_storage_cache: std::collections::HashMap<i64, (u64, u64)>, | |
| 299 | + | /// True once we've populated `vfs_storage_cache` for this panel-open. Reset | |
| 300 | + | /// each time `show_panel` transitions to false so reopening the panel gets | |
| 301 | + | /// fresh numbers. | |
| 302 | + | pub vfs_storage_fetched: bool, | |
| 228 | 303 | } | |
| 229 | 304 | ||
| 230 | 305 | impl Default for SyncUiState { | |
| @@ -232,12 +307,19 @@ impl Default for SyncUiState { | |||
| 232 | 307 | Self { | |
| 233 | 308 | show_panel: false, | |
| 234 | 309 | encryption_input: String::new(), | |
| 310 | + | encryption_confirm_input: String::new(), | |
| 235 | 311 | auth_code_input: String::new(), | |
| 236 | 312 | api_key_input: String::new(), | |
| 237 | 313 | setup_status: SyncSetupStatus::Idle, | |
| 238 | 314 | pending_action: None, | |
| 239 | 315 | subscription_loading: false, | |
| 316 | + | subscription_loading_at: None, | |
| 240 | 317 | checkout_loading: false, | |
| 318 | + | checkout_loading_at: None, | |
| 319 | + | pending_disconnect: false, | |
| 320 | + | auth_url: None, | |
| 321 | + | vfs_storage_cache: std::collections::HashMap::new(), | |
| 322 | + | vfs_storage_fetched: false, | |
| 241 | 323 | } | |
| 242 | 324 | } | |
| 243 | 325 | } | |
| @@ -319,11 +401,103 @@ pub struct MidiUiState { | |||
| 319 | 401 | } | |
| 320 | 402 | ||
| 321 | 403 | /// A top-level imported folder with a user-editable tag input. | |
| 404 | + | #[derive(Clone)] | |
| 322 | 405 | pub struct FolderTagEntry { | |
| 323 | 406 | pub folder: ImportedFolder, | |
| 324 | 407 | pub tag_input: String, | |
| 325 | 408 | } | |
| 326 | 409 | ||
| 410 | + | /// Which long-running operation was cancelled. Drives the acknowledgement | |
| 411 | + | /// screen's copy (file-vs-sample noun, destination-folder line, etc.). | |
| 412 | + | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 413 | + | pub enum CancelKind { | |
| 414 | + | Import, | |
| 415 | + | Analysis, | |
| 416 | + | Export, | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | /// Rolling progress samples for a long-running operation. Used by the | |
| 420 | + | /// import / analysis / export progress screens to compute and display a | |
| 421 | + | /// throughput rate and estimated time remaining (M-11). Samples are | |
| 422 | + | /// deduplicated by `completed` so per-frame repaints don't grow the buffer. | |
| 423 | + | pub struct OperationProgress { | |
| 424 | + | pub started_at: std::time::Instant, | |
| 425 | + | samples: Vec<(std::time::Instant, usize)>, | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | impl OperationProgress { | |
| 429 | + | pub fn new() -> Self { | |
| 430 | + | Self { | |
| 431 | + | started_at: std::time::Instant::now(), | |
| 432 | + | samples: Vec::new(), | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | /// Record the latest `completed` count. No-op if the count hasn't moved. | |
| 437 | + | pub fn record(&mut self, completed: usize) { | |
| 438 | + | let now = std::time::Instant::now(); | |
| 439 | + | let should_push = self | |
| 440 | + | .samples | |
| 441 | + | .last() | |
| 442 | + | .map(|(_, c)| *c != completed) | |
| 443 | + | .unwrap_or(true); | |
| 444 | + | if should_push { | |
| 445 | + | self.samples.push((now, completed)); | |
| 446 | + | } | |
| 447 | + | // Keep the last ~10 seconds of samples; older entries drag the rate | |
| 448 | + | // away from the present and stale the ETA. | |
| 449 | + | self.samples | |
| 450 | + | .retain(|(t, _)| now.duration_since(*t).as_secs_f64() < 10.0); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | /// Items per second over the recent window. `None` until we have enough | |
| 454 | + | /// data to predict. | |
| 455 | + | pub fn rate(&self) -> Option<f64> { | |
| 456 | + | if self.samples.len() < 2 { | |
| 457 | + | return None; | |
| 458 | + | } | |
| 459 | + | let (t0, c0) = *self.samples.first()?; | |
| 460 | + | let (t1, c1) = *self.samples.last()?; | |
| 461 | + | let dt = t1.duration_since(t0).as_secs_f64(); | |
| 462 | + | if dt < 0.5 { | |
| 463 | + | return None; | |
| 464 | + | } | |
| 465 | + | let dc = c1.saturating_sub(c0) as f64; | |
| 466 | + | if dc <= 0.0 { | |
| 467 | + | return None; | |
| 468 | + | } | |
| 469 | + | Some(dc / dt) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// Human-formatted ETA from current `completed` / `total`. Returns `None` | |
| 473 | + | /// when the rate is unknown, the operation is effectively done, or the | |
| 474 | + | /// projected wait is short enough that the readout would just churn. | |
| 475 | + | pub fn eta(&self, completed: usize, total: usize) -> Option<String> { | |
| 476 | + | let rate = self.rate()?; | |
| 477 | + | if total <= completed { | |
| 478 | + | return None; | |
| 479 | + | } | |
| 480 | + | let remaining = (total - completed) as f64; | |
| 481 | + | let secs = (remaining / rate) as u64; | |
| 482 | + | if secs < 5 { | |
| 483 | + | return None; | |
| 484 | + | } | |
| 485 | + | let mins = secs / 60; | |
| 486 | + | let s = secs % 60; | |
| 487 | + | Some(if mins > 0 { | |
| 488 | + | format!("{mins}m {s}s remaining") | |
| 489 | + | } else { | |
| 490 | + | format!("{s}s remaining") | |
| 491 | + | }) | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | impl Default for OperationProgress { | |
| 496 | + | fn default() -> Self { | |
| 497 | + | Self::new() | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | ||
| 327 | 501 | /// Current import/analysis workflow state. | |
| 328 | 502 | pub enum ImportMode { | |
| 329 | 503 | None, | |
| @@ -342,6 +516,9 @@ pub enum ImportMode { | |||
| 342 | 516 | completed: usize, | |
| 343 | 517 | current_name: String, | |
| 344 | 518 | walking: bool, | |
| 519 | + | /// Running file count during the walk phase (m-12). Zero once | |
| 520 | + | /// `walking` flips to false — use `total` thereafter. | |
| 521 | + | walking_count: usize, | |
| 345 | 522 | total_bytes: u64, | |
| 346 | 523 | loose_files: bool, | |
| 347 | 524 | }, | |
| @@ -383,6 +560,16 @@ pub enum ImportMode { | |||
| 383 | 560 | errors: Vec<(String, String)>, | |
| 384 | 561 | }, | |
| 385 | 562 | ReviewErrors, | |
| 563 | + | /// Acknowledgement screen after the user cancels a long-running operation. | |
| 564 | + | /// Surfaces what landed vs what was discarded so the user isn't left to | |
| 565 | + | /// guess whether to re-run, restore, or move on. `destination` is set only | |
| 566 | + | /// for export — names the folder where partial files may sit. | |
| 567 | + | OperationCancelled { | |
| 568 | + | kind: CancelKind, | |
| 569 | + | completed: usize, | |
| 570 | + | total: usize, | |
| 571 | + | destination: Option<PathBuf>, | |
| 572 | + | }, | |
| 386 | 573 | } | |
| 387 | 574 | ||
| 388 | 575 | /// A sample with its analysis results and pending tag suggestions. | |
| @@ -497,16 +684,35 @@ impl Selection { | |||
| 497 | 684 | ||
| 498 | 685 | /// Select all items. | |
| 499 | 686 | pub fn select_all(&mut self, len: usize) { | |
| 687 | + | self.select_all_from(0, len); | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | /// Select every item in `start..len`. Used so Cmd+A on a list with a | |
| 691 | + | /// ".." parent entry can skip index 0 — the parent isn't a sample and | |
| 692 | + | /// must never become part of a bulk operation. | |
| 693 | + | pub fn select_all_from(&mut self, start: usize, len: usize) { | |
| 500 | 694 | self.selected.clear(); | |
| 501 | - | for i in 0..len { | |
| 695 | + | for i in start..len { | |
| 502 | 696 | self.selected.insert(i); | |
| 503 | 697 | } | |
| 504 | - | if len > 0 { | |
| 505 | - | self.anchor = 0; | |
| 698 | + | if len > start { | |
| 699 | + | self.anchor = start; | |
| 506 | 700 | self.focus = len - 1; | |
| 507 | 701 | } | |
| 508 | 702 | } | |
| 509 | 703 | ||
| 704 | + | /// Invert the selection over `0..len`. Indices currently selected become | |
| 705 | + | /// unselected; previously unselected indices become selected. | |
| 706 | + | pub fn invert(&mut self, len: usize) { | |
| 707 | + | let new_selected: std::collections::HashSet<usize> = | |
| 708 | + | (0..len).filter(|i| !self.selected.contains(i)).collect(); | |
| 709 | + | if let Some(&first) = new_selected.iter().min() { | |
| 710 | + | self.anchor = first; | |
| 711 | + | self.focus = first; | |
| 712 | + | } | |
| 713 | + | self.selected = new_selected; | |
| 714 | + | } | |
| 715 | + | ||
| 510 | 716 | /// Check whether `idx` is in the current selection. | |
| 511 | 717 | pub fn contains(&self, idx: usize) -> bool { | |
| 512 | 718 | self.selected.contains(&idx) |
| @@ -9,12 +9,15 @@ use super::widgets; | |||
| 9 | 9 | ||
| 10 | 10 | /// Draw the detail panel content for the currently selected sample. | |
| 11 | 11 | pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 12 | + | if state.selection.count() > 1 { | |
| 13 | + | draw_multi_summary(ui, state); | |
| 14 | + | return; | |
| 15 | + | } | |
| 16 | + | ||
| 12 | 17 | let node = match state.selected_node() { | |
| 13 | 18 | Some(n) => n, | |
| 14 | 19 | None => { | |
| 15 | - | ui.centered_and_justified(|ui| { | |
| 16 | - | ui.label(egui::RichText::new("Select a sample").color(theme::text_muted())); | |
| 17 | - | }); | |
| 20 | + | widgets::empty_state(ui, "Select a sample", None, None); | |
| 18 | 21 | return; | |
| 19 | 22 | } | |
| 20 | 23 | }; | |
| @@ -50,6 +53,36 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 50 | 53 | }; | |
| 51 | 54 | ||
| 52 | 55 | let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0); | |
| 56 | + | // Hover indicator: paint a vertical accent_blue line at the cursor X | |
| 57 | + | // and a time label above it so the user can see where a click-to-seek | |
| 58 | + | // would land before committing. | |
| 59 | + | if resp.hovered() { | |
| 60 | + | if let Some(pos) = resp.hover_pos() { | |
| 61 | + | let rect = resp.rect; | |
| 62 | + | let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); | |
| 63 | + | let total_secs = waveform_data.duration as f32; | |
| 64 | + | let cursor_secs = normalized * total_secs; | |
| 65 | + | ui.painter().line_segment( | |
| 66 | + | [ | |
| 67 | + | egui::pos2(pos.x, rect.top()), | |
| 68 | + | egui::pos2(pos.x, rect.bottom()), | |
| 69 | + | ], | |
| 70 | + | egui::Stroke::new(1.0, theme::accent_blue()), | |
| 71 | + | ); | |
| 72 | + | let label = format!( | |
| 73 | + | "{:.0}:{:02.0}", | |
| 74 | + | (cursor_secs / 60.0).floor(), | |
| 75 | + | cursor_secs % 60.0, | |
| 76 | + | ); | |
| 77 | + | ui.painter().text( | |
| 78 | + | egui::pos2(pos.x, rect.top() - 2.0), | |
| 79 | + | egui::Align2::CENTER_BOTTOM, | |
| 80 | + | label, | |
| 81 | + | egui::FontId::proportional(10.0), | |
| 82 | + | theme::text_secondary(), | |
| 83 | + | ); | |
| 84 | + | } | |
| 85 | + | } | |
| 53 | 86 | // Click-to-seek: map the click's X position to a 0.0–1.0 fraction | |
| 54 | 87 | // within the waveform rect, then set the playback cursor to that frame. | |
| 55 | 88 | if resp.clicked() { | |
| @@ -78,11 +111,14 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 78 | 111 | ||
| 79 | 112 | // Sample name | |
| 80 | 113 | ui.label(egui::RichText::new(&node.node.name).strong().size(14.0)); | |
| 81 | - | ui.add_space(8.0); | |
| 114 | + | ui.add_space(theme::space::MD); | |
| 82 | 115 | ||
| 83 | 116 | // Analysis metadata grid | |
| 84 | 117 | if let Some(ref analysis) = state.selected_analysis { | |
| 85 | - | ui.group(|ui| { | |
| 118 | + | egui::CollapsingHeader::new("Metadata") | |
| 119 | + | .id_salt("detail_metadata_section") | |
| 120 | + | .default_open(true) | |
| 121 | + | .show(ui, |ui| { | |
| 86 | 122 | egui::Grid::new("detail_metadata") | |
| 87 | 123 | .num_columns(2) | |
| 88 | 124 | .spacing([8.0, theme::grid_row_spacing()]) | |
| @@ -145,22 +181,29 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 145 | 181 | } | |
| 146 | 182 | ||
| 147 | 183 | ui.add_space(theme::section_spacing()); | |
| 148 | - | ui.separator(); | |
| 149 | - | ui.add_space(theme::section_spacing() * 0.5); | |
| 150 | 184 | ||
| 151 | - | // Tags section | |
| 152 | - | ui.label(egui::RichText::new("Tags").color(theme::text_secondary())); | |
| 185 | + | egui::CollapsingHeader::new("Tags") | |
| 186 | + | .id_salt("detail_tags_section") | |
| 187 | + | .default_open(true) | |
| 188 | + | .show(ui, |ui| { | |
| 153 | 189 | if state.selected_tags.is_empty() { | |
| 154 | 190 | ui.label(egui::RichText::new("No tags").color(theme::text_muted())); | |
| 155 | 191 | } else { | |
| 156 | 192 | ui.horizontal_wrapped(|ui| { | |
| 157 | 193 | let tags = state.selected_tags.clone(); | |
| 158 | 194 | for tag in tags.iter() { | |
| 159 | - | if widgets::tag_chip_removable(ui, tag) { | |
| 160 | - | // Remove tag | |
| 195 | + | if widgets::tag_chip_removable(ui, tag, true) { | |
| 196 | + | // Remove tag and push an undoable entry so Cmd+Z restores it. | |
| 161 | 197 | if let Some(ref hash) = node.node.sample_hash { | |
| 162 | - | let _ = state.backend.remove_tag(hash, tag); | |
| 163 | - | state.refresh_selected_tags(); | |
| 198 | + | let hash_str = hash.to_string(); | |
| 199 | + | if state.backend.remove_tag(hash, tag).is_ok() { | |
| 200 | + | state.push_undo(crate::state::UndoOp::TagRemove { | |
| 201 | + | hash: hash_str, | |
| 202 | + | tag: tag.clone(), | |
| 203 | + | }); | |
| 204 | + | state.status = format!("Removed tag \"{tag}\""); | |
| 205 | + | state.refresh_selected_tags(); | |
| 206 | + | } | |
| 164 | 207 | } | |
| 165 | 208 | } | |
| 166 | 209 | } | |
| @@ -174,6 +217,11 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 174 | 217 | .hint_text("Add tag (use dots: genre.house)") | |
| 175 | 218 | .desired_width(ui.available_width() - 40.0), | |
| 176 | 219 | ); | |
| 220 | + | // Honor the Tab-from-table shortcut: focus the tag input on this frame. | |
| 221 | + | if state.focus_tag_input { | |
| 222 | + | resp.request_focus(); | |
| 223 | + | state.focus_tag_input = false; | |
| 224 | + | } | |
| 177 | 225 | if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) | |
| 178 | 226 | || ui.small_button("+").on_hover_text("Add tag").clicked() | |
| 179 | 227 | { | |
| @@ -192,62 +240,393 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 192 | 240 | } | |
| 193 | 241 | }); | |
| 194 | 242 | ||
| 195 | - | // Tag suggestions based on classification | |
| 243 | + | // Tag suggestions based on classification. Per-classification dismissals | |
| 244 | + | // let the user say "I never tag kicks with `percussion`" once and have | |
| 245 | + | // the suggestion stop appearing on every future kick. | |
| 196 | 246 | if let Some(ref analysis) = state.selected_analysis { | |
| 197 | 247 | if let Some(ref class) = analysis.classification { | |
| 198 | 248 | let class_str = class.to_string(); | |
| 199 | - | let suggestions = classification_tag_suggestions(&class_str, &state.selected_tags); | |
| 249 | + | let dismissed_for_class = state | |
| 250 | + | .dismissed_suggestions | |
| 251 | + | .get(&class_str) | |
| 252 | + | .cloned() | |
| 253 | + | .unwrap_or_default(); | |
| 254 | + | let suggestions: Vec<&'static str> = | |
| 255 | + | classification_tag_suggestions(&class_str, &state.selected_tags) | |
| 256 | + | .into_iter() | |
| 257 | + | .filter(|s| !dismissed_for_class.iter().any(|d| d == s)) | |
| 258 | + | .collect(); | |
| 200 | 259 | if !suggestions.is_empty() { | |
| 201 | - | ui.add_space(6.0); | |
| 260 | + | ui.add_space(theme::space::SM); | |
| 202 | 261 | ui.horizontal_wrapped(|ui| { | |
| 203 | - | ui.label(egui::RichText::new("Suggest:").small().color(theme::text_muted())); | |
| 262 | + | ui.label( | |
| 263 | + | egui::RichText::new(format!("Suggest (from {class_str}):")) | |
| 264 | + | .small() | |
| 265 | + | .color(theme::text_muted()), | |
| 266 | + | ); | |
| 204 | 267 | for sug in &suggestions { | |
| 205 | - | if ui.small_button( | |
| 206 | - | egui::RichText::new(format!("+{sug}")).small().color(theme::accent_blue()) | |
| 207 | - | ).on_hover_text(format!("Add tag: {sug}")).clicked() { | |
| 268 | + | if ui | |
| 269 | + | .small_button( | |
| 270 | + | egui::RichText::new(format!("+{sug}")) | |
| 271 | + | .small() | |
| 272 | + | .color(theme::accent_blue()), | |
| 273 | + | ) | |
| 274 | + | .on_hover_text(format!("Add tag: {sug}")) | |
| 275 | + | .clicked() | |
| 276 | + | { | |
| 208 | 277 | if let Some(ref hash) = node.node.sample_hash { | |
| 209 | 278 | let _ = state.backend.add_tag(hash, sug); | |
| 210 | 279 | state.refresh_selected_tags(); | |
| 211 | 280 | } | |
| 212 | 281 | } | |
| 282 | + | // Painted X (two crossed line_segments) — matches the | |
| 283 | + | // Phase 4 M-8 X-icon precedent in instrument_panel.rs | |
| 284 | + | // rather than a literal "x" glyph. Muted stroke since | |
| 285 | + | // this is a secondary dismiss, not a danger action. | |
| 286 | + | let icon_size = egui::vec2(14.0, 14.0); | |
| 287 | + | let (icon_rect, icon_resp) = | |
| 288 | + | ui.allocate_exact_size(icon_size, egui::Sense::click()); | |
| 289 | + | let icon_resp = icon_resp.on_hover_text(format!( | |
| 290 | + | "Never suggest \"{sug}\" on {class_str} samples again" | |
| 291 | + | )); | |
| 292 | + | let pad = 3.5; | |
| 293 | + | let p1 = icon_rect.min + egui::vec2(pad, pad); | |
| 294 | + | let p2 = icon_rect.max - egui::vec2(pad, pad); | |
| 295 | + | let p3 = egui::pos2(icon_rect.min.x + pad, icon_rect.max.y - pad); | |
| 296 | + | let p4 = egui::pos2(icon_rect.max.x - pad, icon_rect.min.y + pad); | |
| 297 | + | let stroke_color = if icon_resp.hovered() { | |
| 298 | + | theme::text_secondary() | |
| 299 | + | } else { | |
| 300 | + | theme::text_muted() | |
| 301 | + | }; | |
| 302 | + | let stroke = egui::Stroke::new(1.2, stroke_color); | |
| 303 | + | let painter = ui.painter(); | |
| 304 | + | painter.line_segment([p1, p2], stroke); | |
| 305 | + | painter.line_segment([p3, p4], stroke); | |
| 306 | + | if icon_resp.clicked() { | |
| 307 | + | state.dismiss_suggestion(&class_str, sug); | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | }); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // M-1: inline Undo for the most recent dismiss. Visible for ~5s | |
| 314 | + | // after the click so the affordance is at the locus of the action. | |
| 315 | + | // Older dismissals still recoverable via Settings → Reset | |
| 316 | + | // suggestions; this is just the fast-path for a stray click. | |
| 317 | + | const UNDO_WINDOW: f32 = 5.0; | |
| 318 | + | let show_undo = state | |
| 319 | + | .last_dismissed_suggestion | |
| 320 | + | .as_ref() | |
| 321 | + | .filter(|(c, _, _)| c == &class_str) | |
| 322 | + | .map(|(_, _, at)| at.elapsed().as_secs_f32() < UNDO_WINDOW); | |
| 323 | + | if show_undo == Some(true) { | |
| 324 | + | let (_, tag, _) = state | |
| 325 | + | .last_dismissed_suggestion | |
| 326 | + | .as_ref() | |
| 327 | + | .expect("checked Some above") | |
| 328 | + | .clone(); | |
| 329 | + | ui.add_space(theme::space::XS); | |
| 330 | + | ui.horizontal(|ui| { | |
| 331 | + | ui.label( | |
| 332 | + | egui::RichText::new(format!("Muted \"{tag}\" for {class_str}.")) | |
| 333 | + | .small() | |
| 334 | + | .color(theme::text_muted()), | |
| 335 | + | ); | |
| 336 | + | if ui | |
| 337 | + | .link( | |
| 338 | + | egui::RichText::new("Undo") | |
| 339 | + | .small() | |
| 340 | + | .color(theme::accent_blue()), | |
| 341 | + | ) | |
| 342 | + | .clicked() | |
| 343 | + | { | |
| 344 | + | state.undo_last_dismissal(); | |
| 213 | 345 | } | |
| 214 | 346 | }); | |
| 347 | + | // Keep repainting so the affordance fades when the timer | |
| 348 | + | // crosses 5s — without this the user could leave focus on a | |
| 349 | + | // stale link. | |
| 350 | + | ui.ctx().request_repaint(); | |
| 215 | 351 | } | |
| 216 | 352 | } | |
| 217 | 353 | } | |
| 218 | 354 | ||
| 355 | + | }); // end of Tags CollapsingHeader | |
| 356 | + | ||
| 357 | + | egui::CollapsingHeader::new("Actions") | |
| 358 | + | .id_salt("detail_actions_section") | |
| 359 | + | .default_open(true) | |
| 360 | + | .show(ui, |ui| { | |
| 361 | + | ui.horizontal(|ui| { | |
| 362 | + | if ui.button("Copy Path").on_hover_text("Copy file path to clipboard").clicked() { | |
| 363 | + | if let Some(path) = state.selected_sample_path() { | |
| 364 | + | state.status = format!("Copied: {path}"); | |
| 365 | + | ui.ctx().copy_text(path); | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if let Some(hash) = &node.node.sample_hash { | |
| 369 | + | let hash = hash.clone(); | |
| 370 | + | if ui.button("Edit").on_hover_text("Open sample editor (E)").clicked() { | |
| 371 | + | state.open_edit_window(&hash); | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | }); | |
| 375 | + | }); | |
| 376 | + | ||
| 377 | + | if let Some(hash) = &node.node.sample_hash { | |
| 378 | + | let hash = hash.clone(); | |
| 379 | + | // M-10: gate Discovery on the analysis features each path needs. | |
| 380 | + | // Find Similar reads spectral_centroid / spectral_bandwidth; | |
| 381 | + | // Find Duplicates reads the peak-envelope fingerprint. Without | |
| 382 | + | // these the button "works" but always returns zero results, | |
| 383 | + | // which reads as a broken feature instead of a missing prereq. | |
| 384 | + | let has_spectral = state | |
| 385 | + | .selected_analysis | |
| 386 | + | .as_ref() | |
| 387 | + | .map(|a| a.spectral_centroid.is_some() || a.spectral_bandwidth.is_some()) | |
| 388 | + | .unwrap_or(false); | |
| 389 | + | let has_fingerprint = state | |
| 390 | + | .selected_analysis | |
| 391 | + | .as_ref() | |
| 392 | + | .map(|a| a.fingerprint.is_some()) | |
| 393 | + | .unwrap_or(false); | |
| 394 | + | egui::CollapsingHeader::new("Discovery") | |
| 395 | + | .id_salt("detail_discovery_section") | |
| 396 | + | .default_open(true) | |
| 397 | + | .show(ui, |ui| { | |
| 398 | + | ui.horizontal(|ui| { | |
| 399 | + | let similar_resp = ui.add_enabled( | |
| 400 | + | has_spectral, | |
| 401 | + | egui::Button::new("Find Similar"), | |
| 402 | + | ); | |
| 403 | + | let similar_resp = if has_spectral { | |
| 404 | + | similar_resp.on_hover_text("Find similar samples (Shift+F)") | |
| 405 | + | } else { | |
| 406 | + | similar_resp.on_disabled_hover_text( | |
| 407 | + | "Re-analyze this sample with spectral features enabled to find similar samples.", | |
| 408 | + | ) | |
| 409 | + | }; | |
| 410 | + | if similar_resp.clicked() { | |
| 411 | + | state.find_similar(&hash); | |
| 412 | + | } | |
| 413 | + | let dup_resp = ui.add_enabled( | |
| 414 | + | has_fingerprint, | |
| 415 | + | egui::Button::new("Find Duplicates"), | |
| 416 | + | ); | |
| 417 | + | let dup_resp = if has_fingerprint { | |
| 418 | + | dup_resp.on_hover_text("Find near-duplicates (Shift+D)") | |
| 419 | + | } else { | |
| 420 | + | dup_resp.on_disabled_hover_text( | |
| 421 | + | "Re-analyze this sample with fingerprinting enabled to find duplicates.", | |
| 422 | + | ) | |
| 423 | + | }; | |
| 424 | + | if dup_resp.clicked() { | |
| 425 | + | state.find_near_duplicates(&hash); | |
| 426 | + | } | |
| 427 | + | }); | |
| 428 | + | }); | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | /// Draw a multi-selection summary: common metadata, union of tags, bulk-edit affordance. | |
| 433 | + | fn draw_multi_summary(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 434 | + | let nodes = state.selected_nodes(); | |
| 435 | + | let samples: Vec<_> = nodes | |
| 436 | + | .iter() | |
| 437 | + | .filter(|n| n.node.sample_hash.is_some()) | |
| 438 | + | .collect(); | |
| 439 | + | let sample_count = samples.len(); | |
| 440 | + | let folder_count = nodes.len().saturating_sub(sample_count); | |
| 441 | + | ||
| 442 | + | let heading = if folder_count == 0 { | |
| 443 | + | format!("{sample_count} samples selected") | |
| 444 | + | } else { | |
| 445 | + | format!( | |
| 446 | + | "{sample_count} samples \u{00B7} {folder_count} folders selected", | |
| 447 | + | ) | |
| 448 | + | }; | |
| 449 | + | ui.label(egui::RichText::new(heading).strong().size(14.0)); | |
| 450 | + | ui.add_space(theme::space::MD); | |
| 451 | + | ||
| 452 | + | if sample_count == 0 { | |
| 453 | + | widgets::empty_state( | |
| 454 | + | ui, | |
| 455 | + | "No sample metadata to summarize", | |
| 456 | + | Some("Select one or more samples to see common fields"), | |
| 457 | + | None, | |
| 458 | + | ); | |
| 459 | + | return; | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // Common metadata: show value if uniform across the selection, otherwise "varies". | |
| 463 | + | fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>> | |
| 464 | + | where | |
| 465 | + | F: Fn(&T) -> Option<V>, | |
| 466 | + | V: PartialEq, | |
| 467 | + | { | |
| 468 | + | let mut iter = items.iter().map(&extract); | |
| 469 | + | let first = iter.next()??; | |
| 470 | + | for v in iter { | |
| 471 | + | match v { | |
| 472 | + | Some(v) if v == first => continue, | |
| 473 | + | Some(_) => return Some(Err(())), | |
| 474 | + | None => return Some(Err(())), | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | Some(Ok(first)) | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | ui.group(|ui| { | |
| 481 | + | egui::Grid::new("detail_multi_metadata") | |
| 482 | + | .num_columns(2) | |
| 483 | + | .spacing([8.0, theme::grid_row_spacing()]) | |
| 484 | + | .show(ui, |ui| { | |
| 485 | + | let bpm = summarize(&samples, |n| n.bpm); | |
| 486 | + | ui.label(egui::RichText::new("BPM").color(theme::text_secondary())); | |
| 487 | + | ui.label(match bpm { | |
| 488 | + | Some(Ok(v)) => widgets::format_bpm(v), | |
| 489 | + | Some(Err(())) => "varies".to_string(), | |
| 490 | + | None => "\u{2014}".to_string(), | |
| 491 | + | }); | |
| 492 | + | ui.end_row(); | |
| 493 | + | ||
| 494 | + | let key = summarize(&samples, |n| n.musical_key.clone()); | |
| 495 | + | ui.label(egui::RichText::new("Key").color(theme::text_secondary())); | |
| 496 | + | ui.label(match key { | |
| 497 | + | Some(Ok(v)) => v, | |
| 498 | + | Some(Err(())) => "varies".to_string(), | |
| 499 | + | None => "\u{2014}".to_string(), | |
| 500 | + | }); | |
| 501 | + | ui.end_row(); | |
| 502 | + | ||
| 503 | + | let class = summarize(&samples, |n| n.classification.clone()); | |
| 504 | + | ui.label(egui::RichText::new("Class").color(theme::text_secondary())); | |
| 505 | + | match class { | |
| 506 | + | Some(Ok(v)) => widgets::classification_badge(ui, &v), | |
| 507 | + | Some(Err(())) => { | |
| 508 | + | ui.label("varies"); | |
| 509 | + | } | |
| 510 | + | None => { | |
| 511 | + | ui.label("\u{2014}"); | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | ui.end_row(); | |
| 515 | + | ||
| 516 | + | let dur = summarize(&samples, |n| n.duration); | |
| 517 | + | ui.label(egui::RichText::new("Duration").color(theme::text_secondary())); | |
| 518 | + | ui.label(match dur { | |
| 519 | + | Some(Ok(v)) => widgets::format_duration(v), | |
| 520 | + | Some(Err(())) => "varies".to_string(), | |
| 521 | + | None => "\u{2014}".to_string(), | |
| 522 | + | }); | |
| 523 | + | ui.end_row(); | |
| 524 | + | }); | |
| 525 | + | }); | |
| 526 | + | ||
| 219 | 527 | ui.add_space(theme::section_spacing()); | |
| 220 | 528 | ui.separator(); | |
| 221 | 529 | ui.add_space(theme::section_spacing() * 0.5); | |
| 222 | 530 | ||
| 223 | - | // Action buttons | |
| 224 | - | ui.horizontal(|ui| { | |
| 225 | - | if ui.button("Copy Path").on_hover_text("Copy file path to clipboard").clicked() { | |
| 226 | - | if let Some(path) = state.selected_sample_path() { | |
| 227 | - | ui.ctx().copy_text(path); | |
| 228 | - | state.status = "Path copied to clipboard".to_string(); | |
| 229 | - | } | |
| 531 | + | // Tag union with per-tag count badges. | |
| 532 | + | widgets::subsection_label(ui, "Tags"); | |
| 533 | + | let mut tag_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new(); | |
| 534 | + | for n in &samples { | |
| 535 | + | for tag in &n.tags { | |
| 536 | + | *tag_counts.entry(tag.clone()).or_insert(0) += 1; | |
| 230 | 537 | } | |
| 231 | - | if let Some(hash) = &node.node.sample_hash { | |
| 232 | - | let hash = hash.clone(); | |
| 233 | - | if ui.button("Edit").on_hover_text("Open sample editor (E)").clicked() { | |
| 234 | - | state.open_edit_window(&hash); | |
| 538 | + | } | |
| 539 | + | if tag_counts.is_empty() { | |
| 540 | + | ui.label(egui::RichText::new("No tags").color(theme::text_muted())); | |
| 541 | + | } else { | |
| 542 | + | let mut entries: Vec<_> = tag_counts.into_iter().collect(); | |
| 543 | + | entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| 544 | + | // Collect the hashes once so the closure that handles a badge click | |
| 545 | + | // doesn't need to re-walk the selection. `samples` borrows from `nodes` | |
| 546 | + | // which borrows from state — capture by value here so we can mutate | |
| 547 | + | // state below. | |
| 548 | + | let all_hashes: Vec<String> = samples | |
| 549 | + | .iter() | |
| 550 | + | .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) | |
| 551 | + | .collect(); | |
| 552 | + | // M-11: actionable partial-coverage badges. Right-click any badge to | |
| 553 | + | // apply / remove the tag across the selection. Full-coverage badges | |
| 554 | + | // still render but expose only "Remove from all" (no Apply needed). | |
| 555 | + | let mut pending_apply: Option<(String, Vec<String>)> = None; | |
| 556 | + | let mut pending_remove: Option<(String, Vec<String>)> = None; | |
| 557 | + | ui.horizontal_wrapped(|ui| { | |
| 558 | + | for (tag, count) in entries { | |
| 559 | + | let full = count == sample_count; | |
| 560 | + | let label = if full { | |
| 561 | + | tag.clone() | |
| 562 | + | } else { | |
| 563 | + | format!("{tag} ({count}/{sample_count})") | |
| 564 | + | }; | |
| 565 | + | let hover = if full { | |
| 566 | + | format!("\"{tag}\" \u{2014} on all {sample_count}. Right-click to remove from all.") | |
| 567 | + | } else { | |
| 568 | + | let missing = sample_count - count; | |
| 569 | + | format!( | |
| 570 | + | "\"{tag}\" \u{2014} on {count} of {sample_count}. Right-click to apply to remaining {missing} or remove from {count}." | |
| 571 | + | ) | |
| 572 | + | }; | |
| 573 | + | let resp = ui | |
| 574 | + | .label( | |
| 575 | + | egui::RichText::new(label) | |
| 576 | + | .small() | |
| 577 | + | .color(theme::accent_blue()), | |
| 578 | + | ) | |
| 579 | + | .on_hover_text(hover); | |
| 580 | + | resp.context_menu(|ui| { | |
| 581 | + | if !full { | |
| 582 | + | let missing = sample_count - count; | |
| 583 | + | if ui | |
| 584 | + | .button(format!("Apply to remaining ({missing})")) | |
| 585 | + | .clicked() | |
| 586 | + | { | |
| 587 | + | let targets: Vec<String> = samples | |
| 588 | + | .iter() | |
| 589 | + | .filter(|n| !n.tags.iter().any(|t| t == &tag)) | |
| 590 | + | .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) | |
| 591 | + | .collect(); | |
| 592 | + | pending_apply = Some((tag.clone(), targets)); | |
| 593 | + | ui.close_menu(); | |
| 594 | + | } | |
| 595 | + | } | |
| 596 | + | let remove_label = if full { | |
| 597 | + | format!("Remove from all ({count})") | |
| 598 | + | } else { | |
| 599 | + | format!("Remove from {count}") | |
| 600 | + | }; | |
| 601 | + | if widgets::danger_button(ui, &remove_label).clicked() { | |
| 602 | + | let targets: Vec<String> = samples | |
| 603 | + | .iter() |
Lines truncated
| @@ -7,23 +7,25 @@ use crate::waveform; | |||
| 7 | 7 | use audiofiles_core::edit::FadeCurve; | |
| 8 | 8 | ||
| 9 | 9 | use super::theme; | |
| 10 | + | use super::widgets; | |
| 10 | 11 | ||
| 11 | 12 | /// Draw the floating sample editor window. Call from the overlay layer. | |
| 12 | 13 | pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) { | |
| 13 | 14 | let mut open = state.edit.show_window; | |
| 14 | - | egui::Window::new("Sample Editor") | |
| 15 | - | .open(&mut open) | |
| 16 | - | .resizable(true) | |
| 17 | - | .collapsible(true) | |
| 18 | - | .default_width(400.0) | |
| 19 | - | .min_width(320.0) | |
| 20 | - | .show(ctx, |ui| { | |
| 21 | - | // In-progress overlay | |
| 22 | - | if state.edit.in_progress { | |
| 15 | + | widgets::tool_window(ctx, "Sample Editor", &mut open, 400.0, 320.0, |ui| { | |
| 16 | + | // In-progress overlay | |
| 17 | + | if state.edit.in_progress { | |
| 23 | 18 | ui.vertical_centered(|ui| { | |
| 24 | - | ui.add_space(8.0); | |
| 19 | + | ui.add_space(theme::space::MD); | |
| 25 | 20 | ui.spinner(); | |
| 26 | 21 | ui.label("Applying edit..."); | |
| 22 | + | // M-11: best-effort cancel. Signals the worker and clears | |
| 23 | + | // in_progress so the UI is interactive even if the worker | |
| 24 | + | // is mid-write — the cancel is advisory, not synchronous. | |
| 25 | + | ui.add_space(theme::space::SM); | |
| 26 | + | if ui.button("Cancel").clicked() { | |
| 27 | + | state.cancel_edit_operation(); | |
| 28 | + | } | |
| 27 | 29 | }); | |
| 28 | 30 | return; | |
| 29 | 31 | } | |
| @@ -91,7 +93,54 @@ fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str | |||
| 91 | 93 | None | |
| 92 | 94 | }; | |
| 93 | 95 | ||
| 94 | - | let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 80.0); | |
| 96 | + | // p-6: 120px matches the detail panel waveform; the edit context wants | |
| 97 | + | // the larger surface for precise trim work (was 80px). | |
| 98 | + | let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0); | |
| 99 | + | ||
| 100 | + | // C-1 part 1: paint the trim preview overlay. Regions outside the | |
| 101 | + | // current [trim_start, trim_end] are dimmed so the user sees what | |
| 102 | + | // *will be removed* before clicking Trim. Slider edges = preview | |
| 103 | + | // edges. Updates live as the user drags. | |
| 104 | + | let trim_start = state.edit.trim_start; | |
| 105 | + | let trim_end = state.edit.trim_end; | |
| 106 | + | if trim_start > 0.001 || trim_end < 0.999 { | |
| 107 | + | let rect = resp.rect; | |
| 108 | + | let painter = ui.painter_at(rect); | |
| 109 | + | let overlay = theme::trim_mute_overlay(); | |
| 110 | + | if trim_start > 0.0 { | |
| 111 | + | let x_end = rect.left() + rect.width() * trim_start; | |
| 112 | + | painter.rect_filled( | |
| 113 | + | egui::Rect::from_min_max(rect.min, egui::pos2(x_end, rect.max.y)), | |
| 114 | + | 0.0, | |
| 115 | + | overlay, | |
| 116 | + | ); | |
| 117 | + | } | |
| 118 | + | if trim_end < 1.0 { | |
| 119 | + | let x_start = rect.left() + rect.width() * trim_end; | |
| 120 | + | painter.rect_filled( | |
| 121 | + | egui::Rect::from_min_max(egui::pos2(x_start, rect.min.y), rect.max), | |
| 122 | + | 0.0, | |
| 123 | + | overlay, | |
| 124 | + | ); | |
| 125 | + | } | |
| 126 | + | // Boundary markers reinforce the cut points when the overlay is | |
| 127 | + | // ambiguous (e.g. nearly-zero trim where the dimmed strip is thin). | |
| 128 | + | let stroke = egui::Stroke::new(1.0, theme::accent_yellow()); | |
| 129 | + | if trim_start > 0.0 { | |
| 130 | + | let x = rect.left() + rect.width() * trim_start; | |
| 131 | + | painter.line_segment( | |
| 132 | + | [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], | |
| 133 | + | stroke, | |
| 134 | + | ); | |
| 135 | + | } | |
| 136 | + | if trim_end < 1.0 { | |
| 137 | + | let x = rect.left() + rect.width() * trim_end; | |
| 138 | + | painter.line_segment( | |
| 139 | + | [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], | |
| 140 | + | stroke, | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | } | |
| 95 | 144 | ||
| 96 | 145 | // Click-to-seek | |
| 97 | 146 | if resp.clicked() { | |
| @@ -130,12 +179,12 @@ fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) { | |||
| 130 | 179 | ui.horizontal_wrapped(|ui| { | |
| 131 | 180 | ui.label(egui::RichText::new(&name).strong().size(12.0)); | |
| 132 | 181 | ui.label( | |
| 133 | - | egui::RichText::new(format!(" {} Hz {:.3}s {}", sr, duration, peak_str)) | |
| 182 | + | egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s \u{00B7} {}", sr, duration, peak_str)) | |
| 134 | 183 | .color(theme::text_muted()) | |
| 135 | 184 | .size(11.0), | |
| 136 | 185 | ); | |
| 137 | 186 | }); | |
| 138 | - | ui.add_space(2.0); | |
| 187 | + | ui.add_space(theme::space::XS); | |
| 139 | 188 | } | |
| 140 | 189 | } | |
| 141 | 190 | ||
| @@ -188,7 +237,7 @@ fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 188 | 237 | let predicted = peak + state.edit.gain_db; | |
| 189 | 238 | if predicted > 0.0 { | |
| 190 | 239 | ui.colored_label( | |
| 191 | - | egui::Color32::from_rgb(255, 80, 80), | |
| 240 | + | theme::accent_red(), | |
| 192 | 241 | format!("Peak: {:.1} dB \u{2192} {:.1} dB (clips!)", peak, predicted), | |
| 193 | 242 | ); | |
| 194 | 243 | } | |
| @@ -206,10 +255,19 @@ fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 206 | 255 | // Normalize | |
| 207 | 256 | ui.horizontal(|ui| { | |
| 208 | 257 | ui.label("Normalize:"); | |
| 258 | + | // M-13: switching mode resets the target to the canonical default for | |
| 259 | + | // the new mode (Peak: -1.0 dBFS, LUFS: -14.0 LUFS). The carried-over | |
| 260 | + | // value is meaningless across modes, so snap to a sane starting point. | |
| 209 | 261 | if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.norm_peak, "Peak")).clicked() { | |
| 262 | + | if !state.edit.norm_peak { | |
| 263 | + | state.edit.norm_target = -1.0; | |
| 264 | + | } | |
| 210 | 265 | state.edit.norm_peak = true; | |
| 211 | 266 | } | |
| 212 | 267 | if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.norm_peak, "LUFS")).clicked() { | |
| 268 | + | if state.edit.norm_peak { | |
| 269 | + | state.edit.norm_target = -14.0; | |
| 270 | + | } | |
| 213 | 271 | state.edit.norm_peak = false; | |
| 214 | 272 | } | |
| 215 | 273 | }); | |
| @@ -248,10 +306,13 @@ fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 248 | 306 | }); | |
| 249 | 307 | ||
| 250 | 308 | ui.horizontal(|ui| { | |
| 309 | + | // m-8: cap raised from 2000 to 10000 ms. Long pads / textures can want | |
| 310 | + | // multi-second fades; the old 2-second ceiling was invisible until hit. | |
| 251 | 311 | ui.add_enabled( | |
| 252 | 312 | !disabled, | |
| 253 | - | egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=2000.0).suffix(" ms"), | |
| 254 | - | ); | |
| 313 | + | egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=10000.0).suffix(" ms"), | |
| 314 | + | ) | |
| 315 | + | .on_hover_text("Maximum fade duration 10s"); | |
| 255 | 316 | egui::ComboBox::from_id_salt("edit_fade_curve") | |
| 256 | 317 | .selected_text(match state.edit.fade_curve { | |
| 257 | 318 | FadeCurve::Linear => "Linear", | |
| @@ -276,6 +337,16 @@ fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 276 | 337 | ||
| 277 | 338 | ui.label(egui::RichText::new("Silence").strong()); | |
| 278 | 339 | ||
| 340 | + | // m-9: clamp Insert/Remove positions to [0, sample_duration_ms] when the | |
| 341 | + | // sample's analysis duration is known. Falls back to the previous unbounded | |
| 342 | + | // range only if duration is missing (un-analyzed sample). Prevents the | |
| 343 | + | // silent-failure / undefined-behaviour case where positions exceed length. | |
| 344 | + | let duration_ms_cap = state | |
| 345 | + | .selected_analysis | |
| 346 | + | .as_ref() | |
| 347 | + | .map(|a| a.duration * 1000.0) | |
| 348 | + | .unwrap_or(f64::MAX); | |
| 349 | + | ||
| 279 | 350 | // Insert silence | |
| 280 | 351 | ui.horizontal(|ui| { | |
| 281 | 352 | ui.label("Insert at:"); | |
| @@ -283,7 +354,7 @@ fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 283 | 354 | !disabled, | |
| 284 | 355 | egui::DragValue::new(&mut state.edit.silence_position_ms) | |
| 285 | 356 | .speed(10.0) | |
| 286 | - | .range(0.0..=f64::MAX) | |
| 357 | + | .range(0.0..=duration_ms_cap) | |
| 287 | 358 | .suffix(" ms"), | |
| 288 | 359 | ); | |
| 289 | 360 | ui.label("Duration:"); | |
| @@ -306,7 +377,7 @@ fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 306 | 377 | !disabled, | |
| 307 | 378 | egui::DragValue::new(&mut state.edit.remove_start_ms) | |
| 308 | 379 | .speed(10.0) | |
| 309 | - | .range(0.0..=f64::MAX) | |
| 380 | + | .range(0.0..=duration_ms_cap) | |
| 310 | 381 | .suffix(" ms"), | |
| 311 | 382 | ); | |
| 312 | 383 | ui.label("to:"); | |
| @@ -314,7 +385,7 @@ fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 314 | 385 | !disabled, | |
| 315 | 386 | egui::DragValue::new(&mut state.edit.remove_end_ms) | |
| 316 | 387 | .speed(10.0) | |
| 317 | - | .range(0.0..=f64::MAX) | |
| 388 | + | .range(0.0..=duration_ms_cap) | |
| 318 | 389 | .suffix(" ms"), | |
| 319 | 390 | ); | |
| 320 | 391 | if ui.add_enabled(!disabled, egui::Button::new("Remove")).clicked() { | |
| @@ -330,32 +401,69 @@ fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 330 | 401 | return; | |
| 331 | 402 | } | |
| 332 | 403 | ||
| 333 | - | ui.label( | |
| 334 | - | egui::RichText::new(format!("Batch Edit ({} samples)", selected_count)) | |
| 335 | - | .strong() | |
| 336 | - | .color(theme::accent_blue()), | |
| 337 | - | ); | |
| 404 | + | // m-13: heading is plain strong (matches other section headers). The | |
| 405 | + | // batch-vs-single distinction moves to a small muted badge so adjacent | |
| 406 | + | // sections stay visually balanced. | |
| 407 | + | ui.horizontal(|ui| { | |
| 408 | + | ui.label(egui::RichText::new("Batch Edit").strong()); | |
| 409 | + | ui.label( | |
| 410 | + | egui::RichText::new(format!("Batch \u{00B7} {} samples", selected_count)) | |
| 411 | + | .small() | |
| 412 | + | .color(theme::text_muted()), | |
| 413 | + | ); | |
| 414 | + | }); | |
| 338 | 415 | ui.label( | |
| 339 | 416 | egui::RichText::new("Apply to all selected samples at once") | |
| 340 | 417 | .small() | |
| 341 | 418 | .color(theme::text_muted()), | |
| 342 | 419 | ); | |
| 343 | - | ui.add_space(4.0); | |
| 344 | - | ||
| 420 | + | ui.add_space(theme::space::SM); | |
| 421 | + | ||
| 422 | + | // M-14: bake the panel's current slider values into the button labels so | |
| 423 | + | // the broadcast nature is explicit. Removes the silent-piggyback footgun | |
| 424 | + | // where the user couldn't tell what value the batch button would use. | |
| 425 | + | // Hover hints are dropped — the label now carries the value. | |
| 426 | + | let norm_target = state.edit.norm_target; | |
| 427 | + | let gain_db = state.edit.gain_db; | |
| 345 | 428 | ui.horizontal(|ui| { | |
| 346 | - | if ui.button("Normalize Peak").on_hover_text(format!("Normalize {} samples to {:.1} dBFS", selected_count, state.edit.norm_target)).clicked() { | |
| 347 | - | state.batch_normalize_peak(state.edit.norm_target); | |
| 429 | + | if ui | |
| 430 | + | .button(format!( | |
| 431 | + | "Normalize {} samples to {:.1} dBFS", | |
| 432 | + | selected_count, norm_target | |
| 433 | + | )) | |
| 434 | + | .clicked() | |
| 435 | + | { | |
| 436 | + | state.batch_normalize_peak(norm_target); | |
| 348 | 437 | } | |
| 349 | - | if ui.button("Normalize LUFS").on_hover_text(format!("Normalize {} samples to {:.1} LUFS", selected_count, state.edit.norm_target)).clicked() { | |
| 350 | - | state.batch_normalize_lufs(state.edit.norm_target); | |
| 438 | + | if ui | |
| 439 | + | .button(format!( | |
| 440 | + | "Normalize {} samples to {:.1} LUFS", | |
| 441 | + | selected_count, norm_target | |
| 442 | + | )) | |
| 443 | + | .clicked() | |
| 444 | + | { | |
| 445 | + | state.batch_normalize_lufs(norm_target); | |
| 351 | 446 | } | |
| 352 | 447 | }); | |
| 353 | 448 | ui.horizontal(|ui| { | |
| 354 | - | if ui.button("Gain").on_hover_text(format!("Apply {:.1} dB to {} samples", state.edit.gain_db, selected_count)).clicked() { | |
| 355 | - | state.batch_gain(state.edit.gain_db); | |
| 449 | + | if ui | |
| 450 | + | .button(format!("Apply {:.1} dB to {} samples", gain_db, selected_count)) | |
| 451 | + | .clicked() | |
| 452 | + | { | |
| 453 | + | state.batch_gain(gain_db); | |
| 356 | 454 | } | |
| 357 | - | if ui.button("Reverse").on_hover_text(format!("Reverse {} samples", selected_count)).clicked() { | |
| 358 | - | state.batch_reverse(); | |
| 455 | + | if ui.button("Reverse").clicked() { | |
| 456 | + | // m-16: gate large-batch Reverse behind a confirm modal. Single- | |
| 457 | + | // sample Reverse is its own undo (click again), but on N samples | |
| 458 | + | // the "click again to undo" trick requires remembering it ran in | |
| 459 | + | // the first place. Threshold 10 keeps small selections frictionless. | |
| 460 | + | if selected_count > 10 { | |
| 461 | + | state.pending_confirm = Some( | |
| 462 | + | crate::state::ConfirmAction::ReverseSamples { count: selected_count }, | |
| 463 | + | ); | |
| 464 | + | } else { | |
| 465 | + | state.batch_reverse(); | |
| 466 | + | } | |
| 359 | 467 | } | |
| 360 | 468 | }); | |
| 361 | 469 | } | |
| @@ -377,6 +485,20 @@ fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 377 | 485 | } | |
| 378 | 486 | } | |
| 379 | 487 | }); | |
| 488 | + | // C-1 part 2: Replace is destructive — the original file content is | |
| 489 | + | // overwritten with no in-app undo (edit operations don't push to the | |
| 490 | + | // global undo stack). Surface the consequence here so the user reads it | |
| 491 | + | // before pressing any Apply button. True per-edit undo would need backend | |
| 492 | + | // snapshot support; deferred. | |
| 493 | + | if matches!(state.edit.result_mode, Some(EditResultMode::Replace)) { | |
| 494 | + | ui.label( | |
| 495 | + | egui::RichText::new( | |
| 496 | + | "Replace mode: original content is overwritten. Switch to Create sibling to keep the original.", | |
| 497 | + | ) | |
| 498 | + | .small() | |
| 499 | + | .color(theme::accent_yellow()), | |
| 500 | + | ); | |
| 501 | + | } | |
| 380 | 502 | } | |
| 381 | 503 | ||
| 382 | 504 | /// Draw the "Replace or Create Sibling?" prompt after first edit. | |
| @@ -385,12 +507,16 @@ fn draw_result_prompt(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 385 | 507 | ui.heading("Edit Result"); | |
| 386 | 508 | ui.separator(); | |
| 387 | 509 | ui.label("How should the edited sample be handled?"); | |
| 388 | - | ui.add_space(8.0); | |
| 510 | + | ui.add_space(theme::space::MD); | |
| 389 | 511 | ||
| 390 | - | let mut remember = false; | |
| 512 | + | // m-10: initialise from the persistent result_mode so the checkbox | |
| 513 | + | // reflects whether the user has already locked in a default. The | |
| 514 | + | // Result section radios are the source of truth; this checkbox mirrors | |
| 515 | + | // their state for visibility, then writes through on submit. | |
| 516 | + | let mut remember = state.edit.result_mode.is_some(); | |
| 391 | 517 | ui.checkbox(&mut remember, "Remember my choice"); | |
| 392 | 518 | ||
| 393 | - | ui.add_space(4.0); | |
| 519 | + | ui.add_space(theme::space::SM); | |
| 394 | 520 | ui.horizontal(|ui| { | |
| 395 | 521 | if ui.button("Replace Original").clicked() { | |
| 396 | 522 | state.confirm_edit_result(EditResultMode::Replace, remember); | |
| @@ -398,6 +524,11 @@ fn draw_result_prompt(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 398 | 524 | if ui.button("Create Sibling").clicked() { | |
| 399 | 525 | state.confirm_edit_result(EditResultMode::Sibling, remember); | |
| 400 | 526 | } | |
| 527 | + | // M-12: third option lets the user back out of the prompt without | |
| 528 | + | // committing the edit. Drops the pending result file. | |
| 529 | + | if ui.button("Discard edit").clicked() { | |
| 530 | + | state.discard_edit_result(); | |
| 531 | + | } | |
| 401 | 532 | }); | |
| 402 | 533 | }); | |
| 403 | 534 | } |
| @@ -7,7 +7,7 @@ use egui; | |||
| 7 | 7 | use crate::state::{BrowserState, ImportMode}; | |
| 8 | 8 | use audiofiles_core::export::{ExportChannels, ExportConfig, ExportFormat}; | |
| 9 | 9 | ||
| 10 | - | use super::theme; | |
| 10 | + | use super::{theme, widgets}; | |
| 11 | 11 | ||
| 12 | 12 | /// Query available disk space on the filesystem containing the given path. | |
| 13 | 13 | #[cfg(unix)] | |
| @@ -58,6 +58,22 @@ fn available_disk_space(_path: &Path) -> Option<u64> { | |||
| 58 | 58 | None | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | /// Effective bytes-per-second of audio under the current export config. | |
| 62 | + | /// Used by the disk-space and AIFF-size pre-flight warnings (M-3 / M-4) so | |
| 63 | + | /// the magnitude warnings reflect the user's actual selection rather than a | |
| 64 | + | /// worst-case heuristic. Defaults (`None` config values) bias high so we err | |
| 65 | + | /// on the side of warning when the user picks "Original". | |
| 66 | + | fn bytes_per_sec_for_config(config: &ExportConfig) -> u64 { | |
| 67 | + | let rate = config.sample_rate.unwrap_or(48_000) as u64; | |
| 68 | + | let depth_bytes = (config.bit_depth.unwrap_or(24) as u64).div_ceil(8); | |
| 69 | + | let channels = match config.channels { | |
| 70 | + | ExportChannels::Mono => 1u64, | |
| 71 | + | ExportChannels::Stereo => 2u64, | |
| 72 | + | ExportChannels::Original => 2u64, | |
| 73 | + | }; | |
| 74 | + | rate.saturating_mul(depth_bytes).saturating_mul(channels) | |
| 75 | + | } | |
| 76 | + | ||
| 61 | 77 | /// Draw the export configuration screen. | |
| 62 | 78 | pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |
| 63 | 79 | let (item_count, profile_count) = match &state.import_mode { | |
| @@ -70,26 +86,30 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 70 | 86 | }; | |
| 71 | 87 | ||
| 72 | 88 | egui::TopBottomPanel::bottom("export_footer").show(ctx, |ui| { | |
| 73 | - | ui.add_space(4.0); | |
| 89 | + | ui.add_space(theme::space::SM); | |
| 74 | 90 | ||
| 75 | 91 | // Warnings | |
| 76 | 92 | if let ImportMode::ConfigureExport { ref items, ref config, ref available_profiles, .. } = state.import_mode { | |
| 77 | - | // AIFF size limit warning: ~24 min stereo 24-bit exceeds u32 chunk limit | |
| 93 | + | // AIFF size limit warning (M-4): the 4 GB chunk limit translates | |
| 94 | + | // to ~124 minutes at the worst-case config (stereo 24-bit 96kHz) | |
| 95 | + | // and considerably more at smaller depths/rates. Compute the | |
| 96 | + | // actual safe duration from the current config rather than warning | |
| 97 | + | // at a fixed 20-minute threshold. Yellow because this is | |
| 98 | + | // anticipation, not error. | |
| 78 | 99 | if config.format == ExportFormat::Aiff { | |
| 79 | 100 | let max_duration = items.iter().filter_map(|i| i.duration).fold(0.0f64, f64::max); | |
| 80 | - | // u32::MAX bytes / (channels * bytes_per_sample * sample_rate) | |
| 81 | - | // Worst case: stereo 24-bit 48kHz = 2 * 3 * 48000 = 288000 bytes/sec | |
| 82 | - | // 4_294_967_295 / 288000 ~ 14913 seconds ~ 248 min | |
| 83 | - | // More restrictive: stereo 24-bit 96kHz = 576000 B/s → ~7456s ~ 124 min | |
| 84 | - | // Conservative threshold: warn at 20 min for any config | |
| 85 | - | if max_duration > 1200.0 { | |
| 101 | + | let bps = bytes_per_sec_for_config(config) as f64; | |
| 102 | + | // 90% of u32::MAX gives headroom for chunk headers + rounding. | |
| 103 | + | let safe_secs = (u32::MAX as f64 * 0.9) / bps.max(1.0); | |
| 104 | + | if max_duration > safe_secs { | |
| 86 | 105 | ui.label( | |
| 87 | - | egui::RichText::new( | |
| 88 | - | "\u{26A0} Warning: AIFF format has a 4 GB chunk size limit. \ | |
| 89 | - | Long samples may fail to export." | |
| 90 | - | ) | |
| 106 | + | egui::RichText::new(format!( | |
| 107 | + | "Warning: AIFF chunks cap at 4 GB. At the current rate/depth/channels, \ | |
| 108 | + | samples longer than ~{:.0} min may fail to export.", | |
| 109 | + | safe_secs / 60.0, | |
| 110 | + | )) | |
| 91 | 111 | .small() | |
| 92 | - | .color(theme::accent_red()), | |
| 112 | + | .color(theme::accent_yellow()), | |
| 93 | 113 | ); | |
| 94 | 114 | } | |
| 95 | 115 | } | |
| @@ -112,13 +132,13 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 112 | 132 | if !over_limit.is_empty() { | |
| 113 | 133 | let msg = if over_limit.len() == 1 { | |
| 114 | 134 | format!( | |
| 115 | - | "\u{26A0} \"{}\" may exceed device file size limit ({:.0} MB)", | |
| 135 | + | "\"{}\" may exceed device file size limit ({:.0} MB)", | |
| 116 | 136 | over_limit[0], | |
| 117 | 137 | max_bytes as f64 / 1_048_576.0, | |
| 118 | 138 | ) | |
| 119 | 139 | } else { | |
| 120 | 140 | format!( | |
| 121 | - | "\u{26A0} {} samples may exceed device file size limit ({:.0} MB)", | |
| 141 | + | "{} samples may exceed device file size limit ({:.0} MB)", | |
| 122 | 142 | over_limit.len(), | |
| 123 | 143 | max_bytes as f64 / 1_048_576.0, | |
| 124 | 144 | ) | |
| @@ -131,25 +151,36 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 131 | 151 | } | |
| 132 | 152 | } | |
| 133 | 153 | ||
| 134 | - | // Disk space check | |
| 154 | + | // Disk space check (M-3): estimate from actual per-item durations | |
| 155 | + | // and the current encoding config rather than a fixed 10 MB/item | |
| 156 | + | // heuristic. Only warn when the projection exceeds available space | |
| 157 | + | // with a 10% headroom. Yellow because this is an anticipation | |
| 158 | + | // warning, not a confirmed failure. | |
| 135 | 159 | if let Some(available) = available_disk_space(&config.destination) { | |
| 136 | - | // Rough estimate: item count * avg sample size. Use 10 MB per item as heuristic. | |
| 137 | - | let estimated_bytes = items.len() as u64 * 10 * 1024 * 1024; | |
| 138 | - | if available < estimated_bytes { | |
| 160 | + | let bps = bytes_per_sec_for_config(config); | |
| 161 | + | let estimated_bytes: u64 = items | |
| 162 | + | .iter() | |
| 163 | + | .filter_map(|i| i.duration) | |
| 164 | + | .map(|d| (d.max(0.0) * bps as f64) as u64) | |
| 165 | + | .sum(); | |
| 166 | + | if estimated_bytes > 0 && (estimated_bytes as f64) * 1.1 > available as f64 { | |
| 139 | 167 | ui.label( | |
| 140 | 168 | egui::RichText::new(format!( | |
| 141 | - | "\u{26A0} Low disk space: {:.1} GB available, estimated {:.1} GB needed", | |
| 169 | + | "Low disk space: {:.1} GB available, ~{:.1} GB needed", | |
| 142 | 170 | available as f64 / 1_073_741_824.0, | |
| 143 | 171 | estimated_bytes as f64 / 1_073_741_824.0, | |
| 144 | 172 | )) | |
| 145 | 173 | .small() | |
| 146 | - | .color(theme::accent_red()), | |
| 174 | + | .color(theme::accent_yellow()), | |
| 147 | 175 | ); | |
| 148 | 176 | } | |
| 149 | 177 | } | |
| 150 | 178 | } | |
| 151 | 179 | ||
| 152 | 180 | ui.horizontal(|ui| { | |
| 181 | + | if ui.button("Cancel").clicked() { | |
| 182 | + | state.import_mode = ImportMode::None; | |
| 183 | + | } | |
| 153 | 184 | if ui.button("Export").clicked() { | |
| 154 | 185 | if let ImportMode::ConfigureExport { ref items, ref config, .. } = | |
| 155 | 186 | state.import_mode | |
| @@ -159,18 +190,14 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 159 | 190 | state.run_export(items, config); | |
| 160 | 191 | } | |
| 161 | 192 | } | |
| 162 | - | ||
| 163 | - | if ui.button("Cancel").clicked() { | |
| 164 | - | state.import_mode = ImportMode::None; | |
| 165 | - | } | |
| 166 | 193 | }); | |
| 167 | - | ui.add_space(2.0); | |
| 194 | + | ui.add_space(theme::space::XS); | |
| 168 | 195 | }); | |
| 169 | 196 | ||
| 170 | 197 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 171 | 198 | egui::ScrollArea::vertical().show(ui, |ui| { | |
| 172 | 199 | ui.heading("Export Samples"); | |
| 173 | - | ui.add_space(4.0); | |
| 200 | + | ui.add_space(theme::space::SM); | |
| 174 | 201 | ui.horizontal(|ui| { | |
| 175 | 202 | ui.label(format!("{item_count} samples to export")); | |
| 176 | 203 | if profile_count > 0 { | |
| @@ -181,7 +208,7 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 181 | 208 | ); | |
| 182 | 209 | } | |
| 183 | 210 | }); | |
| 184 | - | ui.add_space(12.0); | |
| 211 | + | ui.add_space(theme::space::LG); | |
| 185 | 212 | ||
| 186 | 213 | // --- Device Profile --- | |
| 187 | 214 | if profile_count > 0 { | |
| @@ -227,23 +254,37 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 227 | 254 | } | |
| 228 | 255 | }); | |
| 229 | 256 | ||
| 230 | - | // Show profile info when one is selected | |
| 257 | + | // Show profile info when one is selected. M-6: surface the | |
| 258 | + | // device's supported formats / rates / depths / channels | |
| 259 | + | // so the user knows what the lock is hiding, not just | |
| 260 | + | // that something is hidden. | |
| 231 | 261 | if let Some(ref name) = config.device_profile { | |
| 232 | 262 | if let Some(profile) = | |
| 233 | 263 | available_profiles.iter().find(|p| &p.name == name) | |
| 234 | 264 | { | |
| 235 | 265 | ui.label( | |
| 236 | - | egui::RichText::new(format!( | |
| 237 | - | "by {} \u{2014} format, rate, depth, and channels will be set to device defaults", | |
| 238 | - | profile.manufacturer, | |
| 239 | - | )) | |
| 240 | - | .small() | |
| 241 | - | .color(theme::text_muted()), | |
| 266 | + | egui::RichText::new(format!("by {}", profile.manufacturer)) | |
| 267 | + | .small() | |
| 268 | + | .color(theme::text_muted()), | |
| 242 | 269 | ); | |
| 270 | + | if let Some(ref summary) = profile.format_summary { | |
| 271 | + | ui.label( | |
| 272 | + | egui::RichText::new(summary) | |
| 273 | + | .small() | |
| 274 | + | .color(theme::text_muted()), | |
| 275 | + | ); | |
| 276 | + | } | |
| 277 | + | // p-5: deferred. DeviceProfile carries only name / | |
| 278 | + | // manufacturer / audio / naming / limits today | |
| 279 | + | // (see audiofiles-core::export::profile). Adding a | |
| 280 | + | // muted "category / notes" line would require new | |
| 281 | + | // schema fields on DeviceProfile + DeviceProfileSummary | |
| 282 | + | // and a backfill across the bundled rhai manifests; | |
| 283 | + | // out of scope for the Phase 5 Polish batch. | |
| 243 | 284 | } | |
| 244 | 285 | } | |
| 245 | 286 | } | |
| 246 | - | ui.add_space(8.0); | |
| 287 | + | ui.add_space(theme::space::MD); | |
| 247 | 288 | } | |
| 248 | 289 | ||
| 249 | 290 | // --- Format --- | |
| @@ -272,7 +313,7 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 272 | 313 | config.format = ExportFormat::Aiff; | |
| 273 | 314 | } | |
| 274 | 315 | } | |
| 275 | - | ui.add_space(8.0); | |
| 316 | + | ui.add_space(theme::space::MD); | |
| 276 | 317 | ||
| 277 | 318 | // --- Audio encoding options (WAV/AIFF) --- | |
| 278 | 319 | let needs_encoding_options = matches!( | |
| @@ -301,7 +342,7 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 301 | 342 | config.sample_rate = *rate; | |
| 302 | 343 | } | |
| 303 | 344 | } | |
| 304 | - | ui.add_space(8.0); | |
| 345 | + | ui.add_space(theme::space::MD); | |
| 305 | 346 | ||
| 306 | 347 | // Bit depth | |
| 307 | 348 | ui.label(egui::RichText::new("Bit Depth").strong()); | |
| @@ -315,7 +356,7 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 315 | 356 | config.bit_depth = *depth; | |
| 316 | 357 | } | |
| 317 | 358 | } | |
| 318 | - | ui.add_space(8.0); | |
| 359 | + | ui.add_space(theme::space::MD); | |
| 319 | 360 | } | |
| 320 | 361 | } | |
| 321 | 362 | ||
| @@ -333,7 +374,7 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 333 | 374 | } | |
| 334 | 375 | } | |
| 335 | 376 | } | |
| 336 | - | ui.add_space(8.0); | |
| 377 | + | ui.add_space(theme::space::MD); | |
| 337 | 378 | } | |
| 338 | 379 | ||
| 339 | 380 | // --- Structure --- | |
| @@ -354,7 +395,7 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 354 | 395 | config.flatten = true; | |
| 355 | 396 | } | |
| 356 | 397 | } | |
| 357 | - | ui.add_space(8.0); | |
| 398 | + | ui.add_space(theme::space::MD); | |
| 358 | 399 | ||
| 359 | 400 | // --- Metadata sidecar --- | |
| 360 | 401 | if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { | |
| @@ -363,25 +404,86 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 363 | 404 | "Include metadata (.audiofiles.json)", | |
| 364 | 405 | ); | |
| 365 | 406 | } | |
| 366 | - | ui.add_space(8.0); | |
| 407 | + | ui.add_space(theme::space::MD); | |
| 367 | 408 | ||
| 368 | 409 | // --- Naming pattern (when flattened) --- | |
| 369 | - | if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { | |
| 410 | + | if let ImportMode::ConfigureExport { ref mut config, ref items, .. } = state.import_mode { | |
| 370 | 411 | if config.flatten { | |
| 371 | 412 | ui.label(egui::RichText::new("Naming Pattern").strong()); | |
| 372 | - | ui.label( | |
| 373 | - | egui::RichText::new( | |
| 374 | - | "Tokens: {name} {bpm} {key} {class} {duration} {n} {nn} {nnn} {ext}", | |
| 375 | - | ) | |
| 376 | - | .small() | |
| 377 | - | .color(theme::text_muted()), | |
| 378 | - | ); | |
| 379 | 413 | let mut pattern = config.naming_pattern.clone().unwrap_or_default(); | |
| 380 | - | if ui.text_edit_singleline(&mut pattern).changed() { | |
| 414 | + | ||
| 415 | + | // Token chips (M-8): clicking appends the token to the | |
| 416 | + | // pattern. egui doesn't surface the cursor position on | |
| 417 | + | // TextEdit so append-to-end is the honest affordance. | |
| 418 | + | ui.horizontal_wrapped(|ui| { | |
| 419 | + | ui.label( | |
| 420 | + | egui::RichText::new("Tokens:") | |
| 421 | + | .small() | |
| 422 | + | .color(theme::text_muted()), | |
| 423 | + | ); | |
| 424 | + | const TOKENS: &[&str] = &[ | |
| 425 | + | "{name}", "{bpm}", "{key}", "{class}", "{duration}", | |
| 426 | + | "{n}", "{nn}", "{nnn}", "{ext}", | |
| 427 | + | ]; | |
| 428 | + | for tok in TOKENS { | |
| 429 | + | if ui | |
| 430 | + | .small_button(*tok) | |
| 431 | + | .on_hover_text("Append this token to the pattern") | |
| 432 | + | .clicked() | |
| 433 | + | { | |
| 434 | + | pattern.push_str(tok); | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | }); | |
| 438 | + | ||
| 439 | + | let changed = ui.text_edit_singleline(&mut pattern).changed(); | |
| 440 | + | if changed | |
| 441 | + | || config.naming_pattern.as_deref().unwrap_or("") != pattern | |
| 442 | + | { | |
| 381 | 443 | config.naming_pattern = | |
| 382 | - | if pattern.is_empty() { None } else { Some(pattern) }; | |
| 444 | + | if pattern.is_empty() { None } else { Some(pattern.clone()) }; | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // Live preview (M-7): parse + resolve against the first | |
| 448 | + | // item's context. Parse errors (unknown token, unclosed | |
| 449 | + | // brace) render in yellow so the user catches typos before | |
| 450 | + | // committing to a 200-file export. | |
| 451 | + | if !pattern.is_empty() { | |
| 452 | + | match audiofiles_core::rename::RenamePattern::parse(&pattern) { | |
| 453 | + | Ok(parsed) => { | |
| 454 | + | if let Some(first) = items.first() { | |
| 455 | + | let ctx = audiofiles_core::rename::RenameContext { | |
| 456 | + | name: first.name.clone(), | |
| 457 | + | extension: first.ext.clone(), | |
| 458 | + | bpm: first.bpm, | |
| 459 | + | musical_key: first.musical_key.clone(), | |
| 460 | + | classification: first.classification.clone(), | |
| 461 | + | duration: first.duration, | |
| 462 | + | index: 0, | |
| 463 | + | }; | |
| 464 | + | let stem = parsed.resolve(&ctx); | |
| 465 | + | let preview = if first.ext.is_empty() { | |
| 466 | + | stem | |
| 467 | + | } else { | |
| 468 | + | format!("{stem}.{}", first.ext) | |
| 469 | + | }; | |
| 470 | + | ui.label( | |
| 471 | + | egui::RichText::new(format!("Preview: {preview}")) | |
| 472 | + | .small() | |
| 473 | + | .color(theme::text_muted()), | |
| 474 | + | ); | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | Err(e) => { | |
| 478 | + | ui.label( | |
| 479 | + | egui::RichText::new(format!("Pattern: {e}")) | |
| 480 | + | .small() | |
| 481 | + | .color(theme::accent_yellow()), | |
| 482 | + | ); | |
| 483 | + | } | |
| 484 | + | } | |
| 383 | 485 | } | |
| 384 | - | ui.add_space(8.0); | |
| 486 | + | ui.add_space(theme::space::MD); | |
| 385 | 487 | } | |
| 386 | 488 | } | |
| 387 | 489 | ||
| @@ -419,27 +521,32 @@ pub fn draw_export_progress(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 419 | 521 | ||
| 420 | 522 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 421 | 523 | ui.heading("Exporting..."); | |
| 422 | - | ui.add_space(16.0); | |
| 524 | + | ui.add_space(theme::space::SECTION); | |
| 423 | 525 | ||
| 424 | 526 | if total > 0 { | |
| 425 | 527 | let progress = completed as f32 / total as f32; | |
| 426 | 528 | ui.add(egui::ProgressBar::new(progress).show_percentage()); | |
| 427 | - | ui.add_space(8.0); | |
| 529 | + | ui.add_space(theme::space::MD); | |
| 428 | 530 | ui.label(format!("{completed} / {total}")); | |
| 429 | 531 | } else { | |
| 430 | - | ui.label("Starting export..."); | |
| 532 | + | // m-4: mirror the spinner pattern from the other progress screens' | |
| 533 | + | // pre-first-item moment so the surface reads as busy rather than stuck. | |
| 534 | + | ui.horizontal(|ui| { | |
| 535 | + | ui.spinner(); | |
| 536 | + | ui.label("Starting export..."); | |
| 537 | + | }); | |
| 431 | 538 | } | |
| 432 | 539 | ||
| 433 | 540 | if !current_name.is_empty() { | |
| 434 | - | ui.add_space(4.0); | |
| 541 | + | ui.add_space(theme::space::SM); | |
| 435 | 542 | ui.label( | |
| 436 | - | egui::RichText::new(¤t_name) | |
| 543 | + | egui::RichText::new(format!("Exporting: {current_name}")) | |
| 437 | 544 | .small() | |
| 438 | 545 | .color(theme::text_muted()), | |
| 439 | 546 | ); | |
| 440 | 547 | } | |
| 441 | 548 | ||
| 442 | - | ui.add_space(16.0); | |
| 549 | + | ui.add_space(theme::space::SECTION); | |
| 443 | 550 | if ui.button("Cancel").clicked() { | |
| 444 | 551 | state.cancel_export(); | |
| 445 | 552 | } | |
| @@ -455,7 +562,7 @@ pub fn draw_export_complete(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 455 | 562 | ||
| 456 | 563 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 457 | 564 | ui.heading("Export Complete"); | |
| 458 | - | ui.add_space(12.0); | |
| 565 | + | ui.add_space(theme::space::LG); | |
| 459 | 566 | ||
| 460 | 567 | if error_count == 0 { | |
| 461 | 568 | ui.label(format!("Successfully exported {total} files.")); | |
| @@ -463,26 +570,55 @@ pub fn draw_export_complete(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 463 | 570 | ui.label(format!( | |
| 464 | 571 | "Exported {total} files with {error_count} errors." | |
| 465 | 572 | )); | |
| 466 | - | ui.add_space(8.0); | |
| 573 | + | ui.add_space(theme::space::MD); | |
| 467 | 574 | ||
| 468 | 575 | if let ImportMode::ExportComplete { ref errors, .. } = state.import_mode { | |
| 469 | 576 | egui::ScrollArea::vertical() | |
| 470 | 577 | .max_height(200.0) | |
| 471 | 578 | .show(ui, |ui| { | |
| 472 | 579 | for (name, err) in errors { | |
| 473 | - | ui.label( | |
| 474 | - | egui::RichText::new(format!("{name}: {err}")) | |
| 475 | - | .small() | |
| 476 | - | .color(theme::text_muted()), | |
| 477 | - | ); | |
| 580 | + | // m-8: name in accent_red + body in text_secondary | |
| 581 | + | // so errors don't blend with hint text. Mirrors the | |
| 582 | + | // two-label layout in progress.rs / summary.rs. | |
| 583 | + | ui.horizontal(|ui| { | |
| 584 | + | ui.label( | |
| 585 | + | egui::RichText::new(name) | |
| 586 | + | .small() | |
| 587 | + | .color(theme::accent_red()), | |
| 588 | + | ); | |
| 589 | + | ui.label( | |
| 590 | + | egui::RichText::new(err) | |
| 591 | + | .small() | |
| 592 | + | .color(theme::text_secondary()), | |
| 593 | + | ); | |
| 594 | + | }); | |
| 478 | 595 | } | |
| 479 | 596 | }); | |
| 480 | 597 | } | |
| 481 | 598 | } | |
| 482 | 599 | ||
| 483 | - | ui.add_space(16.0); | |
| 484 | - | if ui.button("Done").clicked() { | |
| 485 | - | state.import_mode = ImportMode::None; | |
| 486 | - | } | |
| 600 | + | ui.add_space(theme::space::SECTION); | |
| 601 | + | ui.horizontal(|ui| { | |
| 602 | + | // m-9: primary_button for the anchor moment of the export flow. | |
| 603 | + | if widgets::primary_button(ui, "Done").clicked() { | |
| 604 | + | state.import_mode = ImportMode::None; | |
| 605 | + | } | |
| 606 | + | // p-1: open the destination folder so users can verify the result | |
| 607 | + | // without navigating Finder/Explorer themselves. Suppressed when | |
| 608 | + | // the destination wasn't stashed (e.g. export driven from outside | |
| 609 | + | // the wizard's run_export path). | |
| 610 | + | if let Some(dest) = state.last_export_destination.clone() { | |
| 611 | + | if ui.button("Open destination folder").clicked() { | |
| 612 | + | #[cfg(target_os = "macos")] | |
| 613 | + | let _ = std::process::Command::new("open").arg(&dest).spawn(); | |
| 614 | + | #[cfg(target_os = "linux")] | |
| 615 | + | let _ = std::process::Command::new("xdg-open").arg(&dest).spawn(); | |
| 616 | + | #[cfg(target_os = "windows")] | |
| 617 | + | let _ = std::process::Command::new("cmd") | |
| 618 | + | .args(["/c", "start", "", &dest.display().to_string()]) | |
| 619 | + | .spawn(); | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | }); | |
| 487 | 623 | }); | |
| 488 | 624 | } |
| @@ -15,7 +15,11 @@ use super::widgets; | |||
| 15 | 15 | use super::file_list_menus::start_os_drag; | |
| 16 | 16 | ||
| 17 | 17 | /// Draw the sortable, multi-column file list. | |
| 18 | - | pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 18 | + | pub fn draw_file_list( | |
| 19 | + | ui: &mut egui::Ui, | |
| 20 | + | state: &mut BrowserState, | |
| 21 | + | sync_manager: Option<&audiofiles_sync::SyncManager>, | |
| 22 | + | ) { | |
| 19 | 23 | // After an OS drag that ends outside the app window, macOS swallows the | |
| 20 | 24 | // mouse-up so egui's pointer state is stale (`resp.dragged()` stays true). | |
| 21 | 25 | // Block new OS drags until egui sees the pointer released or a 2s safety | |
| @@ -33,31 +37,29 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 33 | 37 | false | |
| 34 | 38 | }; | |
| 35 | 39 | ||
| 36 | - | // Empty state: no contents, at root level, no active search | |
| 40 | + | // Empty state: no contents, at root level, no active search. | |
| 41 | + | // The first-run guided onboarding has a custom layout (numbered steps with | |
| 42 | + | // an inline Import link); other empty states route through `empty_state`. | |
| 37 | 43 | if state.contents.is_empty() && state.current_dir.is_none() && state.search_query.is_empty() && !state.search_filter.is_active() { | |
| 38 | - | ui.vertical_centered(|ui| { | |
| 39 | - | ui.add_space(ui.available_height() * 0.15); | |
| 40 | - | ||
| 41 | - | if state.show_first_launch_hint { | |
| 42 | - | // First-run guided onboarding | |
| 44 | + | if state.show_first_launch_hint { | |
| 45 | + | ui.vertical_centered(|ui| { | |
| 46 | + | ui.add_space(ui.available_height() * 0.08); | |
| 43 | 47 | ui.label( | |
| 44 | 48 | egui::RichText::new("Welcome to audiofiles") | |
| 45 | 49 | .size(22.0) | |
| 46 | 50 | .color(theme::text_primary()), | |
| 47 | 51 | ); | |
| 48 | - | ui.add_space(16.0); | |
| 52 | + | ui.add_space(theme::space::SECTION); | |
| 49 | 53 | ui.label( | |
| 50 | - | egui::RichText::new("Get started in seconds:") | |
| 54 | + | egui::RichText::new("Three steps to your first sample:") | |
| 51 | 55 | .color(theme::text_secondary()), | |
| 52 | 56 | ); | |
| 53 | - | ui.add_space(12.0); | |
| 57 | + | ui.add_space(theme::space::LG); | |
| 54 | 58 | ||
| 55 | - | // Step 1 | |
| 56 | 59 | ui.horizontal(|ui| { | |
| 57 | - | ui.label(egui::RichText::new("1.").strong().color(theme::accent_blue())); | |
| 60 | + | widgets::step_number(ui, 1); | |
| 58 | 61 | ui.label("Drop a folder of samples onto this window, or click "); | |
| 59 | 62 | if ui.link("Import").clicked() { | |
| 60 | - | // Trigger quick import file dialog | |
| 61 | 63 | if let Some(path) = rfd::FileDialog::new() | |
| 62 | 64 | .set_title("Quick Import Folder") | |
| 63 | 65 | .pick_folder() | |
| @@ -66,89 +68,105 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 66 | 68 | } | |
| 67 | 69 | } | |
| 68 | 70 | }); | |
| 69 | - | ui.add_space(6.0); | |
| 71 | + | ui.add_space(theme::space::SM); | |
| 70 | 72 | ||
| 71 | - | // Step 2 | |
| 72 | 73 | ui.horizontal(|ui| { | |
| 73 | - | ui.label(egui::RichText::new("2.").strong().color(theme::accent_blue())); | |
| 74 | + | widgets::step_number(ui, 2); | |
| 74 | 75 | ui.label("audiofiles will analyze BPM, key, and type automatically"); | |
| 75 | 76 | }); | |
| 76 | - | ui.add_space(6.0); | |
| 77 | + | ui.add_space(theme::space::SM); | |
| 77 | 78 | ||
| 78 | - | // Step 3 | |
| 79 | 79 | ui.horizontal(|ui| { | |
| 80 | - | ui.label(egui::RichText::new("3.").strong().color(theme::accent_blue())); | |
| 80 | + | widgets::step_number(ui, 3); | |
| 81 | 81 | ui.label("Browse, filter, preview, and export to your hardware"); | |
| 82 | 82 | }); | |
| 83 | 83 | ||
| 84 | - | ui.add_space(20.0); | |
| 84 | + | ui.add_space(theme::space::LG); | |
| 85 | 85 | ui.label( | |
| 86 | 86 | egui::RichText::new("Your files stay where they are \u{2014} audiofiles only indexes them.") | |
| 87 | 87 | .small() | |
| 88 | 88 | .color(theme::text_muted()), | |
| 89 | 89 | ); | |
| 90 | - | ui.add_space(8.0); | |
| 90 | + | ui.add_space(theme::space::MD); | |
| 91 | 91 | ui.label( | |
| 92 | - | egui::RichText::new("F1 for keyboard shortcuts \u{00B7} Right-click for options") | |
| 92 | + | egui::RichText::new("Press F1 for shortcuts \u{00B7} Right-click samples for options") | |
| 93 | 93 | .small() | |
| 94 | 94 | .color(theme::text_muted()), | |
| 95 | 95 | ); | |
| 96 | - | } else { | |
| 97 | - | // Returning user, empty vault | |
| 98 | - | ui.label( | |
| 99 | - | egui::RichText::new("\u{1F3B5}") | |
| 100 | - | .size(48.0) | |
| 101 | - | .color(theme::text_muted()), | |
| 102 | - | ); | |
| 103 | - | ui.add_space(12.0); | |
| 104 | - | ui.label( | |
| 105 | - | egui::RichText::new("No samples yet") | |
| 106 | - | .size(20.0) | |
| 107 | - | .color(theme::text_secondary()), | |
| 108 | - | ); | |
| 109 | - | ui.add_space(8.0); | |
| 110 | - | ui.label( | |
| 111 | - | egui::RichText::new("Drop audio files here or click Import to get started.") | |
| 112 | - | .color(theme::text_muted()), | |
| 113 | - | ); | |
| 96 | + | }); | |
| 97 | + | } else { | |
| 98 | + | let clicked = widgets::empty_state( | |
| 99 | + | ui, | |
| 100 | + | "No samples yet", | |
| 101 | + | Some("Drop audio files here, or import a folder to get started."), | |
| 102 | + | Some(widgets::EmptyStateCta { | |
| 103 | + | label: "Import folder...", | |
| 104 | + | tooltip: Some("Choose a folder of samples to import"), | |
| 105 | + | }), | |
| 106 | + | ); | |
| 107 | + | if clicked { | |
| 108 | + | if let Some(path) = rfd::FileDialog::new() | |
| 109 | + | .set_title("Import folder") | |
| 110 | + | .pick_folder() | |
| 111 | + | { | |
| 112 | + | state.quick_import_folder(path); | |
| 113 | + | } | |
| 114 | 114 | } | |
| 115 | - | }); | |
| 115 | + | // Quiet link to bring the welcome screen back if the user dismissed it. | |
| 116 | + | ui.vertical_centered(|ui| { | |
| 117 | + | ui.add_space(theme::space::LG); | |
| 118 | + | if ui.link(egui::RichText::new("Show welcome").small().color(theme::text_muted())).clicked() { | |
| 119 | + | state.show_welcome(); | |
| 120 | + | } | |
| 121 | + | }); | |
| 122 | + | } | |
| 116 | 123 | return; | |
| 117 | 124 | } | |
| 118 | 125 | ||
| 119 | 126 | // Empty state: filters active but no results in this folder | |
| 120 | 127 | if state.contents.is_empty() && (state.search_filter.is_active() || !state.search_query.is_empty()) { | |
| 121 | - | ui.vertical_centered(|ui| { | |
| 122 | - | ui.add_space(ui.available_height() * 0.25); | |
| 123 | - | ui.label( | |
| 124 | - | egui::RichText::new("\u{1F50D}") | |
| 125 | - | .size(36.0) | |
| 126 | - | .color(theme::text_muted()), | |
| 127 | - | ); | |
| 128 | - | ui.add_space(12.0); | |
| 129 | - | ui.label( | |
| 130 | - | egui::RichText::new("No matches in this folder") | |
| 131 | - | .size(18.0) | |
| 132 | - | .color(theme::text_secondary()), | |
| 133 | - | ); | |
| 134 | - | ui.add_space(8.0); | |
| 135 | - | let filter_count = state.search_filter.active_count(); | |
| 136 | - | let hint = if filter_count > 0 && !state.search_query.is_empty() { | |
| 137 | - | format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 138 | - | } else if filter_count > 0 { | |
| 139 | - | format!("{} filter{} active — try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 140 | - | } else { | |
| 141 | - | "No samples match your search in this folder.".to_string() | |
| 142 | - | }; | |
| 143 | - | ui.label(egui::RichText::new(&hint).color(theme::text_muted())); | |
| 144 | - | ui.add_space(12.0); | |
| 145 | - | if ui.button("Clear Filters").clicked() { | |
| 146 | - | state.search_filter.clear(); | |
| 147 | - | state.search_query.clear(); | |
| 148 | - | state.apply_search(); | |
| 128 | + | let filter_count = state.search_filter.active_count(); | |
| 129 | + | let hint = if filter_count > 0 && !state.search_query.is_empty() { | |
| 130 | + | format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 131 | + | } else if filter_count > 0 { | |
| 132 | + | format!("{} filter{} active \u{2014} try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 133 | + | } else { | |
| 134 | + | "No samples match your search in this folder.".to_string() | |
| 135 | + | }; | |
| 136 | + | // C-3: label names every part of the action. The CTA clears both | |
| 137 | + | // filters and the search query — matching the toolbar's already-fixed | |
| 138 | + | // "Clear search and filters" rename from Phase 4 M-4. | |
| 139 | + | if widgets::empty_state( | |
| 140 | + | ui, | |
| 141 | + | "No matches in this folder", | |
| 142 | + | Some(&hint), | |
| 143 | + | Some(widgets::EmptyStateCta { label: "Clear search and filters", tooltip: None }), | |
| 144 | + | ) { | |
| 145 | + | state.search_filter.clear(); | |
| 146 | + | state.search_query.clear(); | |
| 147 | + | state.apply_search(); | |
| 148 | + | } | |
| 149 | + | return; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Sync first-touch banner: surfaces once after the first import. Suppressed | |
| 153 | + | // while the welcome hint is up (user hasn't imported yet) and dismissed | |
| 154 | + | // permanently once the user clicks either button. | |
| 155 | + | if state.show_sync_intro && !state.show_first_launch_hint { | |
| 156 | + | widgets::info_banner( | |
| 157 | + | ui, | |
| 158 | + | "Your library is local. Set up cloud sync to back it up and use it on other devices.", | |
| 159 | + | ); | |
| 160 | + | ui.horizontal(|ui| { | |
| 161 | + | if ui.button("Maybe later").clicked() { | |
| 162 | + | state.dismiss_sync_intro(); | |
| 163 | + | } | |
| 164 | + | if ui.button("Set up sync").clicked() { | |
| 165 | + | state.sync.show_panel = true; | |
| 166 | + | state.dismiss_sync_intro(); | |
| 149 | 167 | } | |
| 150 | 168 | }); | |
| 151 | - | return; | |
| 169 | + | ui.add_space(theme::space::SM); | |
| 152 | 170 | } | |
| 153 | 171 | ||
| 154 | 172 | let row_height = state.row_height; | |
| @@ -188,7 +206,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 188 | 206 | if col_cfg.show_tags { | |
| 189 | 207 | table = table.column(Column::exact(120.0)); | |
| 190 | 208 | } | |
| 191 | - | table = table.column(Column::exact(28.0)); // Play button | |
| 209 | + | table = table.column(Column::exact(36.0)); // Play button | |
| 192 | 210 | ||
| 193 | 211 | // Snapshot column visibility flags into local bools. The `col_cfg` borrow | |
| 194 | 212 | // from `state` can't survive into the table-builder closures which also | |
| @@ -203,39 +221,44 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 203 | 221 | // Snapshot sort state so the header closure doesn't borrow `state` mutably. | |
| 204 | 222 | let sort_col = state.sort_column; | |
| 205 | 223 | let sort_dir = state.sort_direction.clone(); | |
| 224 | + | // While similarity / duplicate search is active, results come back sorted | |
| 225 | + | // by similarity score — letting the user click a column header to "sort | |
| 226 | + | // by name" silently in that view would scramble the ranking. Disable | |
| 227 | + | // header clicks instead so the score order stays trustworthy. | |
| 228 | + | let sort_enabled = state.similarity_search_hash.is_none(); | |
| 206 | 229 | let clicked_col = std::cell::Cell::new(None::<SortColumn>); | |
| 207 | 230 | ||
| 208 | 231 | table | |
| 209 | 232 | .header(20.0, |mut header| { | |
| 210 | 233 | header.col(|ui| { | |
| 211 | - | if draw_sort_header(ui, "Name", SortColumn::Name, &sort_col, &sort_dir) { | |
| 234 | + | if draw_sort_header(ui, "Name", SortColumn::Name, &sort_col, &sort_dir, sort_enabled) { | |
| 212 | 235 | clicked_col.set(Some(SortColumn::Name)); | |
| 213 | 236 | } | |
| 214 | 237 | }); | |
| 215 | 238 | if show_duration { | |
| 216 | 239 | header.col(|ui| { | |
| 217 | - | if draw_sort_header(ui, "Dur", SortColumn::Duration, &sort_col, &sort_dir) { | |
| 240 | + | if draw_sort_header(ui, "Dur", SortColumn::Duration, &sort_col, &sort_dir, sort_enabled) { | |
| 218 | 241 | clicked_col.set(Some(SortColumn::Duration)); | |
| 219 | 242 | } | |
| 220 | 243 | }); | |
| 221 | 244 | } | |
| 222 | 245 | if show_classification { | |
| 223 | 246 | header.col(|ui| { | |
| 224 | - | if draw_sort_header(ui, "Class", SortColumn::Classification, &sort_col, &sort_dir) { | |
| 247 | + | if draw_sort_header(ui, "Class", SortColumn::Classification, &sort_col, &sort_dir, sort_enabled) { | |
| 225 | 248 | clicked_col.set(Some(SortColumn::Classification)); | |
| 226 | 249 | } | |
| 227 | 250 | }); | |
| 228 | 251 | } | |
| 229 | 252 | if show_bpm { | |
| 230 | 253 | header.col(|ui| { | |
| 231 | - | if draw_sort_header(ui, "BPM", SortColumn::Bpm, &sort_col, &sort_dir) { | |
| 254 | + | if draw_sort_header(ui, "BPM", SortColumn::Bpm, &sort_col, &sort_dir, sort_enabled) { | |
| 232 | 255 | clicked_col.set(Some(SortColumn::Bpm)); | |
| 233 | 256 | } | |
| 234 | 257 | }); | |
| 235 | 258 | } | |
| 236 | 259 | if show_key { | |
| 237 | 260 | header.col(|ui| { | |
| 238 | - | if draw_sort_header(ui, "Key", SortColumn::Key, &sort_col, &sort_dir) { | |
| 261 | + | if draw_sort_header(ui, "Key", SortColumn::Key, &sort_col, &sort_dir, sort_enabled) { | |
| 239 | 262 | clicked_col.set(Some(SortColumn::Key)); | |
| 240 | 263 | } | |
| 241 | 264 | }); | |
| @@ -250,7 +273,9 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 250 | 273 | ui.label(egui::RichText::new("Tags").color(theme::text_secondary())); | |
| 251 | 274 | }); | |
| 252 | 275 | } | |
| 253 | - | header.col(|_ui| {}); // Play | |
| 276 | + | header.col(|ui| { | |
| 277 | + | ui.label(egui::RichText::new("Play").color(theme::text_muted())); | |
| 278 | + | }); | |
| 254 | 279 | }) | |
| 255 | 280 | .body(|mut body| { | |
| 256 | 281 | // ".." parent entry | |
| @@ -259,7 +284,14 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 259 | 284 | let selected = state.selection.contains(0); | |
| 260 | 285 | row.set_selected(selected); | |
| 261 | 286 | row.col(|ui| { | |
| 262 | - | let resp = ui.selectable_label(selected, " .."); | |
| 287 | + | // Parent ".." entry: render muted so it reads as | |
| 288 | + | // navigation rather than a sample row, and is visually | |
| 289 | + | // distinct when scanning a selection with Cmd+A. | |
| 290 | + | let resp = ui.selectable_label( | |
| 291 | + | selected, | |
| 292 | + | egui::RichText::new(" Up") | |
| 293 | + | .color(theme::text_secondary()), | |
| 294 | + | ); | |
| 263 | 295 | if resp.clicked() { | |
| 264 | 296 | handle_click(state, 0, ui); | |
| 265 | 297 | } | |
| @@ -289,7 +321,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 289 | 321 | let drag_blocked = os_drag_blocked; | |
| 290 | 322 | #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] | |
| 291 | 323 | let drag_blocked = false; | |
| 292 | - | draw_name_column(ui, state, node, row_idx, selected, drag_blocked); | |
| 324 | + | draw_name_column(ui, state, node, row_idx, selected, drag_blocked, sync_manager); | |
| 293 | 325 | }); | |
| 294 | 326 | ||
| 295 | 327 | // Analysis columns (duration, classification, BPM, key, peak dB, tags) | |
| @@ -299,23 +331,48 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 299 | 331 | show_key, show_peak_db, show_tags, | |
| 300 | 332 | ); | |
| 301 | 333 | ||
| 302 | - | // Play button | |
| 334 | + | // Play (or Download for cloud-only) button. C-1: cloud-only | |
| 335 | + | // samples used to render an empty cell, leaving the row | |
| 336 | + | // looking half-broken. The Download button surfaces the | |
| 337 | + | // recovery path that previously lived only in the | |
| 338 | + | // right-click context menu. | |
| 303 | 339 | row.col(|ui| { | |
| 304 | - | if node.node.node_type == NodeType::Sample && !node.cloud_only { | |
| 305 | - | if let Some(hash) = &node.node.sample_hash { | |
| 306 | - | let is_playing = state.previewing_hash.as_deref() == Some(hash) | |
| 307 | - | && state.shared.preview.lock().playing; | |
| 308 | - | let btn_text = if is_playing { "\u{23F9}" } else { "\u{25B6}" }; | |
| 309 | - | let hover = if is_playing { "Stop preview (Space)" } else { "Play preview (Space)" }; | |
| 310 | - | if ui.small_button(btn_text).on_hover_text(hover).clicked() { | |
| 311 | - | if is_playing { | |
| 312 | - | state.stop_preview(); | |
| 340 | + | if node.node.node_type != NodeType::Sample { | |
| 341 | + | return; | |
| 342 | + | } | |
| 343 | + | let Some(hash) = node.node.sample_hash.as_ref() else { return; }; | |
| 344 | + | if node.cloud_only { | |
| 345 | + | if let Some(sync) = sync_manager { | |
| 346 | + | if ui | |
| 347 | + | .button("Download") | |
| 348 | + | .on_hover_text("Fetch this sample from the cloud") | |
| 349 | + | .clicked() | |
| 350 | + | { | |
| 351 | + | let hash_str = hash.to_string(); | |
| 352 | + | if sync.download_sample(&hash_str) { | |
| 353 | + | state.status = format!( | |
| 354 | + | "Downloading {}...", | |
| 355 | + | node.node.name, | |
| 356 | + | ); | |
| 313 | 357 | } else { | |
| 314 | - | let hash = hash.clone(); | |
| 315 | - | state.trigger_preview(&hash); | |
| 358 | + | state.status = | |
| 359 | + | "Sync not ready — open the Sync panel first".to_string(); | |
| 316 | 360 | } | |
| 317 | 361 | } | |
| 318 | 362 | } | |
| 363 | + | } else { | |
| 364 | + | let is_playing = state.previewing_hash.as_deref() == Some(hash) | |
| 365 | + | && state.shared.preview.lock().playing; | |
| 366 | + | let btn_text = if is_playing { "Stop" } else { "Play" }; | |
| 367 | + | let hover = if is_playing { "Stop preview (Space)" } else { "Play preview (Space)" }; | |
| 368 | + | if ui.button(btn_text).on_hover_text(hover).clicked() { | |
| 369 | + | if is_playing { | |
| 370 | + | state.stop_preview(); | |
| 371 | + | } else { | |
| 372 | + | let hash = hash.clone(); | |
| 373 | + | state.trigger_preview(&hash); | |
| 374 | + | } | |
| 375 | + | } | |
| 319 | 376 | } | |
| 320 | 377 | }); | |
| 321 | 378 | }); | |
| @@ -378,6 +435,7 @@ fn draw_name_column( | |||
| 378 | 435 | row_idx: usize, | |
| 379 | 436 | selected: bool, | |
| 380 | 437 | os_drag_blocked: bool, | |
| 438 | + | sync_manager: Option<&audiofiles_sync::SyncManager>, | |
| 381 | 439 | ) { | |
| 382 | 440 | let icon = match node.node.node_type { | |
| 383 | 441 | NodeType::Directory => "\u{1F4C1} ", | |
| @@ -397,11 +455,22 @@ fn draw_name_column( | |||
| 397 | 455 | // Add drag sense for native OS drag-out (Finder/DAW). | |
| 398 | 456 | // Response::interact() re-registers the SAME widget id with | |
| 399 | 457 | // click+drag sense so egui tracks drags on the selectable_label. | |
| 458 | + | // While the post-drag cooldown is active, surface the wait state in the | |
| 459 | + | // hover text — an invisible 2-second lockout otherwise reads as the app | |
| 460 | + | // having silently stopped responding. | |
| 400 | 461 | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 401 | 462 | let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample { | |
| 463 | + | let hover = if os_drag_blocked { | |
| 464 | + | "Just dragged — ready again in a moment." | |
| 465 | + | } else { | |
| 466 | + | "Drag to Finder or DAW" | |
| 467 | + | }; | |
| 402 | 468 | resp.interact(egui::Sense::drag()) | |
| 403 | - | .on_hover_text_at_pointer("Drag to Finder or DAW") | |
| 469 | + | .on_hover_text_at_pointer(hover) | |
| 404 | 470 | } else { | |
| 471 | + | // C-1: cloud-only hover dropped — the Download button in the Play | |
| 472 | + | // column now carries the affordance, so the redundant name-column | |
| 473 | + | // hover would just compete for the user's attention. | |
| 405 | 474 | resp | |
| 406 | 475 | }; | |
| 407 | 476 | ||
| @@ -450,15 +519,35 @@ fn draw_name_column( | |||
| 450 | 519 | } | |
| 451 | 520 | start_os_drag(state); | |
| 452 | 521 | } | |
| 522 | + | // Trace when the post-drag cooldown swallows a user's drag attempt. The | |
| 523 | + | // hover-text change above is the user-visible signal; this log helps | |
| 524 | + | // diagnose any lingering drag-pipeline bugs that hide behind the cooldown. | |
| 525 | + | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 526 | + | if os_drag_blocked | |
| 527 | + | && node.node.node_type == NodeType::Sample | |
| 528 | + | && !node.cloud_only | |
| 529 | + | && resp.dragged() | |
| 530 | + | && resp.drag_delta().length() > 4.0 | |
| 531 | + | { | |
| 532 | + | tracing::warn!( | |
| 533 | + | row = row_idx, | |
| 534 | + | "OS drag suppressed by post-drag cooldown" | |
| 535 | + | ); | |
| 536 | + | } | |
| 453 | 537 | ||
| 454 | - | // Context menu: show bulk operations when right-clicking | |
| 455 | - | // a row that's part of a multi-selection, otherwise show | |
| 456 | - | // single-item actions (preview, copy path, delete). | |
| 538 | + | // Context menu: show bulk operations when right-clicking a row that's part | |
| 539 | + | // of the multi-selection. Right-clicking a row that is NOT in the current | |
| 540 | + | // selection collapses the selection to just that row first (matches Finder / | |
| 541 | + | // Explorer / Nautilus convention), so the menu's actions clearly target the | |
| 542 | + | // row under the cursor instead of an out-of-frame multi-selection. | |
| 543 | + | if resp.secondary_clicked() && !state.selection.contains(row_idx) { | |
| 544 | + | state.selection.set_single(row_idx); | |
| 545 | + | } | |
| 457 | 546 | resp.context_menu(|ui| { | |
| 458 | 547 | if state.selection.count() > 1 && state.selection.contains(row_idx) { | |
| 459 | 548 | draw_multi_context_menu(ui, state); | |
| 460 | 549 | } else { | |
| 461 | - | draw_context_menu(ui, state, row_idx, node); | |
| 550 | + | draw_context_menu(ui, state, row_idx, node, sync_manager); | |
| 462 | 551 | } | |
| 463 | 552 | }); | |
| 464 | 553 | } | |
| @@ -556,24 +645,36 @@ fn draw_sort_header( | |||
| 556 | 645 | column: SortColumn, | |
| 557 | 646 | current: &SortColumn, | |
| 558 | 647 | direction: &SortDirection, | |
| 648 | + | enabled: bool, | |
| 559 | 649 | ) -> bool { | |
| 560 | 650 | let is_active = std::mem::discriminant(current) == std::mem::discriminant(&column); | |
| 651 | + | // Reserve a fixed-width glyph slot at the right of every sortable header | |
| 652 | + | // so the column layout doesn't shift when the user toggles the active sort. | |
| 653 | + | // Inactive columns paint a muted middle-dot in the same slot; active | |
| 654 | + | // columns paint the direction arrow. | |
| 561 | 655 | let arrow = if is_active { | |
| 562 | 656 | match direction { | |
| 563 | 657 | SortDirection::Ascending => " \u{25B2}", | |
| 564 | 658 | SortDirection::Descending => " \u{25BC}", | |
| 565 | 659 | } | |
| 566 | 660 | } else { | |
| 567 | - | "" | |
| 661 | + | " \u{00B7}" | |
| 568 | 662 | }; | |
| 569 | 663 | ||
| 570 | 664 | let text = format!("{label}{arrow}"); | |
| 571 | - | let rich = if is_active { | |
| 572 | - | egui::RichText::new(text).strong().color(theme::accent_blue()) | |
| 573 | - | } else { | |
| 574 | - | egui::RichText::new(text).color(theme::text_secondary()) | |
| 575 | - | }; | |
| 576 | - | ||
| 577 | - | ui.add(egui::Label::new(rich).sense(egui::Sense::click())).clicked() | |
| 665 | + | if !enabled { | |
| 666 | + | // Render disabled headers as a sensed label so on_disabled_hover_text | |
| 667 | + | // surfaces — otherwise the user clicks an inert header and gets silence. | |
| 668 | + | ui.add_enabled( | |
| 669 | + | false, | |
| 670 | + | egui::Label::new(egui::RichText::new(text).color(theme::text_muted())) | |
| 671 | + | .sense(egui::Sense::click()), | |
| 672 | + | ) | |
| 673 | + | .on_disabled_hover_text( | |
| 674 | + | "Sort disabled - results ranked by similarity. Clear the similarity search to re-enable column sort.", | |
| 675 | + | ); | |
| 676 | + | return false; | |
| 677 | + | } | |
| 678 | + | super::widgets::selectable_row_secondary(ui, is_active, text).clicked() | |
| 578 | 679 | } | |
| 579 | 680 |
| @@ -6,6 +6,7 @@ use crate::state::BrowserState; | |||
| 6 | 6 | use audiofiles_core::vfs::NodeType; | |
| 7 | 7 | ||
| 8 | 8 | use super::theme; | |
| 9 | + | use super::widgets; | |
| 9 | 10 | ||
| 10 | 11 | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 11 | 12 | use crate::drag_out; | |
| @@ -18,6 +19,7 @@ pub fn draw_context_menu( | |||
| 18 | 19 | state: &mut BrowserState, | |
| 19 | 20 | row_idx: usize, | |
| 20 | 21 | node: &audiofiles_core::vfs::VfsNodeWithAnalysis, | |
| 22 | + | sync_manager: Option<&audiofiles_sync::SyncManager>, | |
| 21 | 23 | ) { | |
| 22 | 24 | match node.node.node_type { | |
| 23 | 25 | NodeType::Sample => { | |
| @@ -27,6 +29,30 @@ pub fn draw_context_menu( | |||
| 27 | 29 | .color(theme::text_muted()) | |
| 28 | 30 | .italics(), | |
| 29 | 31 | ); | |
| 32 | + | // Targeted download for the row under the cursor. Falls back | |
| 33 | + | // gracefully when sync isn't configured (CLAP plugin, dev | |
| 34 | + | // builds without an embedded API key) by hiding the item. | |
| 35 | + | if let Some(sync) = sync_manager { | |
| 36 | + | if let Some(hash) = &node.node.sample_hash { | |
| 37 | + | if ui | |
| 38 | + | .button("Download") | |
| 39 | + | .on_hover_text("Fetch this sample from the cloud to local storage") | |
| 40 | + | .clicked() | |
| 41 | + | { | |
| 42 | + | let hash = hash.to_string(); | |
| 43 | + | if sync.download_sample(&hash) { | |
| 44 | + | state.status = format!( | |
| 45 | + | "Downloading {}...", | |
| 46 | + | node.node.name | |
| 47 | + | ); | |
| 48 | + | } else { | |
| 49 | + | state.status = | |
| 50 | + | "Sync not ready — open the Sync panel first".to_string(); | |
| 51 | + | } | |
| 52 | + | ui.close_menu(); | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 30 | 56 | ui.separator(); | |
| 31 | 57 | } | |
| 32 | 58 | if !node.cloud_only && ui.button("Preview").clicked() { | |
| @@ -38,19 +64,47 @@ pub fn draw_context_menu( | |||
| 38 | 64 | } | |
| 39 | 65 | if ui.button("Copy Path").clicked() { | |
| 40 | 66 | if let Some(path) = state.selected_sample_path() { | |
| 67 | + | state.status = format!("Copied: {path}"); | |
| 41 | 68 | ui.ctx().copy_text(path); | |
| 42 | - | state.status = "Path copied to clipboard".to_string(); | |
| 43 | 69 | } | |
| 44 | 70 | ui.close_menu(); | |
| 45 | 71 | } | |
| 46 | - | if ui.button("Find Similar (Shift+F)").clicked() { | |
| 72 | + | // M-6: one-click jump to the file in the system file manager. | |
| 73 | + | // macOS / Windows highlight the file itself; Linux falls back to | |
| 74 | + | // opening the parent directory (no widely-supported select flag). | |
| 75 | + | #[cfg(target_os = "macos")] | |
| 76 | + | let reveal_label = "Reveal in Finder"; | |
| 77 | + | #[cfg(target_os = "windows")] | |
| 78 | + | let reveal_label = "Show in Explorer"; | |
| 79 | + | #[cfg(target_os = "linux")] | |
| 80 | + | let reveal_label = "Open Containing Folder"; | |
| 81 | + | if !node.cloud_only && ui.button(reveal_label).clicked() { | |
| 82 | + | if let Some(path) = state.selected_sample_path() { | |
| 83 | + | #[cfg(target_os = "macos")] | |
| 84 | + | let _ = std::process::Command::new("open").args(["-R", &path]).spawn(); | |
| 85 | + | #[cfg(target_os = "windows")] | |
| 86 | + | let _ = std::process::Command::new("explorer") | |
| 87 | + | .arg(format!("/select,{}", path)) | |
| 88 | + | .spawn(); | |
| 89 | + | #[cfg(target_os = "linux")] | |
| 90 | + | { | |
| 91 | + | let parent = std::path::Path::new(&path) | |
| 92 | + | .parent() | |
| 93 | + | .map(|p| p.to_path_buf()) | |
| 94 | + | .unwrap_or_else(|| std::path::PathBuf::from(&path)); | |
| 95 | + | let _ = std::process::Command::new("xdg-open").arg(&parent).spawn(); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ui.close_menu(); | |
| 99 | + | } | |
| 100 | + | if ui.button("Find Similar (Shift+F)").clicked() { | |
| 47 | 101 | if let Some(hash) = &node.node.sample_hash { | |
| 48 | 102 | let hash = hash.clone(); | |
| 49 | 103 | state.find_similar(&hash); | |
| 50 | 104 | } | |
| 51 | 105 | ui.close_menu(); | |
| 52 | 106 | } | |
| 53 | - | if ui.button("Find Duplicates (Shift+D)").clicked() { | |
| 107 | + | if ui.button("Find Duplicates (Shift+D)").clicked() { | |
| 54 | 108 | if let Some(hash) = &node.node.sample_hash { | |
| 55 | 109 | let hash = hash.clone(); | |
| 56 | 110 | state.find_near_duplicates(&hash); | |
| @@ -76,7 +130,7 @@ pub fn draw_context_menu( | |||
| 76 | 130 | } | |
| 77 | 131 | if is_in_collection { | |
| 78 | 132 | if let Some(active_id) = state.active_collection { | |
| 79 | - | if ui.button("Remove from Collection").clicked() { | |
| 133 | + | if widgets::danger_button(ui, "Remove from Collection").clicked() { | |
| 80 | 134 | let _ = state.backend.remove_from_collection(active_id, &hash_clone); | |
| 81 | 135 | state.refresh_collections(); | |
| 82 | 136 | state.activate_collection(active_id); | |
| @@ -88,7 +142,7 @@ pub fn draw_context_menu( | |||
| 88 | 142 | if !node.cloud_only { | |
| 89 | 143 | if let Some(hash) = &node.node.sample_hash { | |
| 90 | 144 | let hash_clone = hash.clone(); | |
| 91 | - | if ui.button("Edit... (E)").clicked() { | |
| 145 | + | if ui.button("Edit... (E)").clicked() { | |
| 92 | 146 | state.open_edit_window(&hash_clone); | |
| 93 | 147 | ui.close_menu(); | |
| 94 | 148 | } | |
| @@ -109,9 +163,36 @@ pub fn draw_context_menu( | |||
| 109 | 163 | state.start_export_flow(Some(vec![node.node.id])); | |
| 110 | 164 | ui.close_menu(); | |
| 111 | 165 | } | |
| 166 | + | // M-7: single-row Re-analyze parity with the multi-row menu. | |
| 167 | + | // Reuses ReanalyzeOverwrite with a one-element vec so the | |
| 168 | + | // backend path matches the bulk case exactly. | |
| 169 | + | if ui | |
| 170 | + | .button("Re-analyze...") | |
| 171 | + | .on_hover_text("Run analysis again on this sample") | |
| 172 | + | .clicked() | |
| 173 | + | { | |
| 174 | + | if let Some(hash) = &node.node.sample_hash { | |
| 175 | + | if let Ok(ext) = state.backend.sample_extension(hash) { | |
| 176 | + | let hashes = vec![(hash.to_string(), ext)]; | |
| 177 | + | let has_existing = node.bpm.is_some() | |
| 178 | + | || node.musical_key.is_some() | |
| 179 | + | || node.classification.is_some(); | |
| 180 | + | if has_existing { | |
| 181 | + | state.pending_confirm = | |
| 182 | + | Some(crate::state::ConfirmAction::ReanalyzeOverwrite { | |
| 183 | + | sample_hashes: hashes, | |
| 184 | + | overwrite_count: 1, | |
| 185 | + | }); | |
| 186 | + | } else { | |
| 187 | + | state.start_analysis_flow(hashes); | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ui.close_menu(); | |
| 192 | + | } | |
| 112 | 193 | } | |
| 113 | 194 | ui.separator(); | |
| 114 | - | if ui.button("Delete").clicked() { | |
| 195 | + | if widgets::danger_button(ui, "Delete").clicked() { | |
| 115 | 196 | state.selection.set_single(row_idx); | |
| 116 | 197 | state.confirm_delete_selected(); | |
| 117 | 198 | ui.close_menu(); | |
| @@ -138,7 +219,7 @@ pub fn draw_context_menu( | |||
| 138 | 219 | ui.close_menu(); | |
| 139 | 220 | } | |
| 140 | 221 | ui.separator(); | |
| 141 | - | if ui.button("Delete").clicked() { | |
| 222 | + | if widgets::danger_button(ui, "Delete").clicked() { | |
| 142 | 223 | state.selection.set_single(row_idx); | |
| 143 | 224 | state.confirm_delete_selected(); | |
| 144 | 225 | ui.close_menu(); | |
| @@ -153,15 +234,26 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 153 | 234 | ui.label(egui::RichText::new(format!("{count} items selected")).strong()); | |
| 154 | 235 | ui.separator(); | |
| 155 | 236 | ||
| 156 | - | if ui.button("Tag... (Cmd+T)").clicked() { | |
| 237 | + | if ui.button("Invert Selection (Cmd+Shift+I)").clicked() { | |
| 238 | + | state.invert_selection(); | |
| 239 | + | ui.close_menu(); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | ui.separator(); | |
| 243 | + | ||
| 244 | + | if ui.button("Tag... (Cmd+T)").clicked() { | |
| 157 | 245 | state.open_bulk_tag_modal(); | |
| 158 | 246 | ui.close_menu(); | |
| 159 | 247 | } | |
| 160 | - | if ui.button("Move to... (Cmd+M)").clicked() { | |
| 248 | + | // m-16: Cmd+M conflicts with the macOS minimize-window shortcut. Label | |
| 249 | + | // advertises Cmd+Shift+M; the actual key binding lives in | |
| 250 | + | // `editor.rs` (search for "Cmd+M: bulk move") and must be updated | |
| 251 | + | // there to match. | |
| 252 | + | if ui.button("Move to... (Cmd+Shift+M)").clicked() { | |
| 161 | 253 | state.open_bulk_move_modal(); | |
| 162 | 254 | ui.close_menu(); | |
| 163 | 255 | } | |
| 164 | - | if ui.button("Rename... (F2)").clicked() { | |
| 256 | + | if ui.button("Rename... (F2)").clicked() { | |
| 165 | 257 | state.open_bulk_rename_modal(); | |
| 166 | 258 | ui.close_menu(); | |
| 167 | 259 | } | |
| @@ -193,7 +285,7 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 193 | 285 | ||
| 194 | 286 | // Remove from Collection (when viewing a collection) | |
| 195 | 287 | if let Some(active_id) = state.active_collection { | |
| 196 | - | if ui.button("Remove from Collection").clicked() { | |
| 288 | + | if widgets::danger_button(ui, "Remove from Collection").clicked() { | |
| 197 | 289 | let nodes = state.selected_nodes(); | |
| 198 | 290 | for n in &nodes { | |
| 199 | 291 | if let Some(hash) = &n.node.sample_hash { | |
| @@ -209,7 +301,8 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 209 | 301 | ui.separator(); | |
| 210 | 302 | ||
| 211 | 303 | if ui.button("Re-analyze...").on_hover_text("Run analysis again on selected samples").clicked() { | |
| 212 | - | let hashes: Vec<(String, String)> = state.selected_nodes() | |
| 304 | + | let selected = state.selected_nodes(); | |
| 305 | + | let hashes: Vec<(String, String)> = selected | |
| 213 | 306 | .iter() | |
| 214 | 307 | .filter_map(|n| { | |
| 215 | 308 | let hash = n.node.sample_hash.as_ref()?; | |
| @@ -217,7 +310,21 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 217 | 310 | Some((hash.to_string(), ext)) | |
| 218 | 311 | }) | |
| 219 | 312 | .collect(); | |
| 220 | - | state.start_analysis_flow(hashes); | |
| 313 | + | // Count how many of the selected samples already have computed values | |
| 314 | + | // — re-analyzing those will overwrite the previous result, which a user | |
| 315 | + | // who hand-tuned the analysis would lose silently otherwise. | |
| 316 | + | let overwrite_count = selected | |
| 317 | + | .iter() | |
| 318 | + | .filter(|n| n.bpm.is_some() || n.musical_key.is_some() || n.classification.is_some()) | |
| 319 | + | .count(); | |
| 320 | + | if overwrite_count > 0 { | |
| 321 | + | state.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite { | |
| 322 | + | sample_hashes: hashes, | |
| 323 | + | overwrite_count, | |
| 324 | + | }); | |
| 325 | + | } else { | |
| 326 | + | state.start_analysis_flow(hashes); | |
| 327 | + | } | |
| 221 | 328 | ui.close_menu(); | |
| 222 | 329 | } | |
| 223 | 330 | ||
| @@ -251,7 +358,7 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 251 | 358 | ||
| 252 | 359 | ui.separator(); | |
| 253 | 360 | ||
| 254 | - | if ui.button("Copy Paths").clicked() { | |
| 361 | + | if ui.button("Copy Path").clicked() { | |
| 255 | 362 | let nodes = state.selected_nodes(); | |
| 256 | 363 | let paths: Vec<String> = nodes | |
| 257 | 364 | .iter() | |
| @@ -263,13 +370,23 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 263 | 370 | }) | |
| 264 | 371 | .collect(); | |
| 265 | 372 | if !paths.is_empty() { | |
| 373 | + | // Include the first path in the status so the user can recognise the | |
| 374 | + | // clipboard contents at a glance — the bare count "Copied N paths" | |
| 375 | + | // gave no way to verify which selection won the race when the user | |
| 376 | + | // copied, then changed selection, then pasted into a DAW. | |
| 377 | + | let first = &paths[0]; | |
| 378 | + | let count = paths.len(); | |
| 379 | + | state.status = if count == 1 { | |
| 380 | + | format!("Copied: {first}") | |
| 381 | + | } else { | |
| 382 | + | format!("Copied: {first} (+{} more)", count - 1) | |
| 383 | + | }; | |
| 266 | 384 | ui.ctx().copy_text(paths.join("\n")); | |
| 267 | - | state.status = format!("Copied {} paths", paths.len()); | |
| 268 | 385 | } | |
| 269 | 386 | ui.close_menu(); | |
| 270 | 387 | } | |
| 271 | 388 | ||
| 272 | - | if ui.button("Delete").clicked() { | |
| 389 | + | if widgets::danger_button(ui, "Delete").clicked() { | |
| 273 | 390 | state.confirm_delete_selected(); | |
| 274 | 391 | ui.close_menu(); | |
| 275 | 392 | } | |
| @@ -282,9 +399,9 @@ pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) | |||
| 282 | 399 | state.dir_create_input.clear(); | |
| 283 | 400 | ui.close_menu(); | |
| 284 | 401 | } | |
| 285 | - | if ui.button("Import Files...").clicked() { | |
| 402 | + | if ui.button("Import files...").clicked() { | |
| 286 | 403 | if let Some(paths) = rfd::FileDialog::new() | |
| 287 | - | .set_title("Import Files") | |
| 404 | + | .set_title("Import files") | |
| 288 | 405 | .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) | |
| 289 | 406 | .pick_files() | |
| 290 | 407 | { | |
| @@ -294,7 +411,10 @@ pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) | |||
| 294 | 411 | } | |
| 295 | 412 | ui.close_menu(); | |
| 296 | 413 | } | |
| 297 | - | if ui.button("Import Folder...").clicked() { | |
| 414 | + | // C-2: matches the toolbar's "Import folder..." (wizard path). The quick | |
| 415 | + | // import shortcut is only offered from the toolbar to keep this menu | |
| 416 | + | // short; users who want quick-import find it there. | |
| 417 | + | if ui.button("Import folder...").clicked() { | |
| 298 | 418 | if let Some(path) = rfd::FileDialog::new().pick_folder() { | |
| 299 | 419 | state.show_import_options(path); | |
| 300 | 420 | } | |
| @@ -302,13 +422,17 @@ pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) | |||
| 302 | 422 | } | |
| 303 | 423 | if state.selection.count() > 0 { | |
| 304 | 424 | ui.separator(); | |
| 305 | - | let label = format!("Deselect ({})", state.selection.count()); | |
| 425 | + | let label = format!("Deselect ({}) (Esc)", state.selection.count()); | |
| 306 | 426 | if ui.button(label).clicked() { | |
| 307 | 427 | state.selection.clear(); | |
| 308 | 428 | state.refresh_selected_tags(); | |
| 309 | 429 | state.refresh_selected_detail(); | |
| 310 | 430 | ui.close_menu(); | |
| 311 | 431 | } | |
| 432 | + | if ui.button("Invert Selection (Cmd+Shift+I)").clicked() { | |
| 433 | + | state.invert_selection(); | |
| 434 | + | ui.close_menu(); | |
| 435 | + | } | |
| 312 | 436 | } | |
| 313 | 437 | } | |
| 314 | 438 |