Skip to main content

max / audiofiles

Implement UX-audit phases 3 and 4 plus surrounding polish Most of the work lands findings from docs/ux-audit/phase-3.md (file list, detail panel, edit panel) and phase-4.md (filter, instrument, settings, sync panels). The rest is in-flight work that landed in the same window — separable in theory, deeply interleaved in practice. Highlights: License activation Classify activation failures into Network / Server / InvalidKey / MachineLimit / Other; render a per-class recovery affordance (Try again, Get a new key, Contact support). Audio output Surface the cpal output-device name in the footer alongside the sample rate so users can see which device preview is bound to. Browser UX Cmd-Shift-I invert selection; Cmd+A skips ".." parent row; Tab focuses detail-panel tag input; Backspace two-step in similarity mode; Cmd+Shift+M for bulk move (avoids macOS minimize). Drop-target visual overlay while dragging into the window. Drop-files preflight modal for Quick-Imports of ≥100 files or ≥1 GiB (with persistent dismissal). Operation-cancelled acknowledgement screen so cancel doesn't silently strand the user; rate + ETA readout for long-running operations; status messages stamp time for the footer's fade. Tag rename (sidebar inline) + remove-globally (with affected sample count); tag-suggestion dismissal is now per-classification persistent with inline Undo and Reset. Sync-intro banner after first import; sync setup confirms password on first encryption setup; Disconnect requires confirmation; per-VFS storage stats in the sync panel; surface sync errors with Retry / Dismiss; loading-state timeout so a dropped network doesn't strand the UI in spinner-forever. Welcome / VFS / first-launch onboarding banners are re-surfaceable from Settings. Reset-columns affordance restores table defaults. Browser-state plumbing New ConfirmAction variants for: SwitchLibrary, RemoveTagGlobally, DisconnectSync, ReanalyzeOverwrite, RemoveFailedSamples, ReverseSamples, DeleteCollection (consolidates one-off prompts). New UndoOp::TagRemove for the inline detail-panel chip removal. OperationProgress tracks rolling samples for rate / ETA. Sync OAuth flow is cancellable (cancel_auth) and the surfaced last_error is dismissable (clear_last_error). Download-one-blob on demand from the row context menu (SyncCommand::DownloadOne → service::download_one_blob). Core relocate_vault repoints an offline vault's registry entry to a new directory (paired with the Locate… affordance). Tag rename / remove-globally helpers in audiofiles-core::tags. DeviceProfileSummary carries a format_summary string so the export profile picker can render constraints inline without refetching the full profile. Import worker Emit WalkProgress events during the pre-walk so the spinner is replaced with a running file count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 00:11 UTC
Commit: c4d95b331fd076ddf9dccbe64757037fb0b57e1a
Parent: 88415ea
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(&notes);
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(&current_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
M docs/todo.md +38