max / audiofiles
7 files changed,
+114 insertions,
-34 deletions
| @@ -37,8 +37,24 @@ impl BrowserState { | |||
| 37 | 37 | Some(ConfirmAction::DeleteVfs { vfs_id, .. }) => { | |
| 38 | 38 | match self.backend.delete_vfs(vfs_id) { | |
| 39 | 39 | Ok(()) => { | |
| 40 | - | self.refresh_vfs_list(); | |
| 41 | - | self.status = "Library deleted".to_string(); | |
| 40 | + | // Start background cleanup for orphaned samples | |
| 41 | + | if self.backend.start_cleanup().is_ok() { | |
| 42 | + | self.import_mode = ImportMode::Cleaning { | |
| 43 | + | completed: 0, | |
| 44 | + | total: 0, | |
| 45 | + | current_name: String::new(), | |
| 46 | + | }; | |
| 47 | + | self.refresh_vfs_list(); | |
| 48 | + | } else { | |
| 49 | + | // Fallback: synchronous cleanup | |
| 50 | + | let orphans = self.backend.remove_orphaned_samples().unwrap_or(0); | |
| 51 | + | self.refresh_vfs_list(); | |
| 52 | + | if orphans > 0 { | |
| 53 | + | self.status = format!("Library deleted ({orphans} samples removed)"); | |
| 54 | + | } else { | |
| 55 | + | self.status = "Library deleted".to_string(); | |
| 56 | + | } | |
| 57 | + | } | |
| 42 | 58 | } | |
| 43 | 59 | Err(e) => self.status = format!("Delete failed: {e}"), | |
| 44 | 60 | } | |
| @@ -86,13 +102,14 @@ impl BrowserState { | |||
| 86 | 102 | .collect() | |
| 87 | 103 | } | |
| 88 | 104 | ||
| 89 | - | /// Push an undoable operation onto the stack (capped at 50 entries). | |
| 105 | + | /// Push an undoable operation onto the stack (capped at 100 entries). | |
| 90 | 106 | fn push_undo(&mut self, op: UndoOp) { | |
| 91 | 107 | self.undo_stack.push(op); | |
| 92 | - | // Cap at 50 entries to bound memory usage. Each UndoOp can hold a full | |
| 108 | + | // Cap at 100 entries to bound memory usage. Each UndoOp can hold a full | |
| 93 | 109 | // subtree snapshot (nodes + tags), so unbounded growth is not acceptable. | |
| 94 | - | if self.undo_stack.len() > 50 { | |
| 95 | - | self.undo_stack.remove(0); | |
| 110 | + | if self.undo_stack.len() > 100 { | |
| 111 | + | let excess = self.undo_stack.len() - 100; | |
| 112 | + | self.undo_stack.drain(..excess); | |
| 96 | 113 | } | |
| 97 | 114 | } | |
| 98 | 115 | ||
| @@ -179,7 +196,8 @@ impl BrowserState { | |||
| 179 | 196 | } | |
| 180 | 197 | let node_ids: Vec<NodeId> = nodes.iter().map(|n| n.node.id).collect(); | |
| 181 | 198 | let names: Vec<String> = nodes.iter().map(|n| n.node.name.clone()).collect(); | |
| 182 | - | let directories = self.backend.list_all_directories(self.current_vfs_id()).unwrap_or_else(|e| { | |
| 199 | + | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 200 | + | let directories = self.backend.list_all_directories(vfs_id).unwrap_or_else(|e| { | |
| 183 | 201 | warn!("Failed to list directories: {e}"); | |
| 184 | 202 | Vec::new() | |
| 185 | 203 | }); |
| @@ -11,7 +11,10 @@ impl BrowserState { | |||
| 11 | 11 | return; | |
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | - | let vfs_id = self.current_vfs_id(); | |
| 14 | + | let Some(vfs_id) = self.current_vfs_id() else { | |
| 15 | + | self.status = "No VFS available".to_string(); | |
| 16 | + | return; | |
| 17 | + | }; | |
| 15 | 18 | let parent_id = self.current_dir; | |
| 16 | 19 | let mut hashes = Vec::new(); | |
| 17 | 20 | ||
| @@ -402,6 +405,33 @@ impl BrowserState { | |||
| 402 | 405 | return true; | |
| 403 | 406 | } | |
| 404 | 407 | ||
| 408 | + | // --- Cleanup events --- | |
| 409 | + | BackendEvent::CleanupProgress { | |
| 410 | + | completed, | |
| 411 | + | total, | |
| 412 | + | current_name, | |
| 413 | + | } => { | |
| 414 | + | self.import_mode = ImportMode::Cleaning { | |
| 415 | + | completed, | |
| 416 | + | total, | |
| 417 | + | current_name, | |
| 418 | + | }; | |
| 419 | + | } | |
| 420 | + | BackendEvent::CleanupComplete { removed, errors } => { | |
| 421 | + | self.refresh_vfs_list(); | |
| 422 | + | if errors > 0 { | |
| 423 | + | self.status = | |
| 424 | + | format!("Library deleted ({removed} samples removed, {errors} errors)"); | |
| 425 | + | } else if removed > 0 { | |
| 426 | + | self.status = | |
| 427 | + | format!("Library deleted ({removed} samples removed)"); | |
| 428 | + | } else { | |
| 429 | + | self.status = "Library deleted".to_string(); | |
| 430 | + | } | |
| 431 | + | self.import_mode = ImportMode::None; | |
| 432 | + | return true; | |
| 433 | + | } | |
| 434 | + | ||
| 405 | 435 | // --- Edit events --- | |
| 406 | 436 | BackendEvent::EditStarted { hash: _ } => { | |
| 407 | 437 | self.edit.in_progress = true; | |
| @@ -563,7 +593,10 @@ impl BrowserState { | |||
| 563 | 593 | ||
| 564 | 594 | /// Begin the export workflow: collect items from the current VFS/selection and show config. | |
| 565 | 595 | pub fn start_export_flow(&mut self, node_ids: Option<Vec<NodeId>>) { | |
| 566 | - | let vfs_id = self.current_vfs_id(); | |
| 596 | + | let Some(vfs_id) = self.current_vfs_id() else { | |
| 597 | + | self.status = "No VFS available".to_string(); | |
| 598 | + | return; | |
| 599 | + | }; | |
| 567 | 600 | ||
| 568 | 601 | let mut items = if let Some(ids) = node_ids { | |
| 569 | 602 | // Export specific selected items: collect subtrees for each | |
| @@ -669,6 +702,14 @@ impl BrowserState { | |||
| 669 | 702 | }; | |
| 670 | 703 | } | |
| 671 | 704 | ||
| 705 | + | /// Cancel the running cleanup and return to idle state. | |
| 706 | + | pub fn cancel_cleanup(&mut self) { | |
| 707 | + | let _ = self.backend.cancel_cleanup(); | |
| 708 | + | self.import_mode = ImportMode::None; | |
| 709 | + | self.refresh_vfs_list(); | |
| 710 | + | self.status = "Cleanup cancelled".to_string(); | |
| 711 | + | } | |
| 712 | + | ||
| 672 | 713 | /// Cancel the running export and return to idle state. | |
| 673 | 714 | pub fn cancel_export(&mut self) { | |
| 674 | 715 | let _ = self.backend.cancel_export(); | |
| @@ -870,7 +911,10 @@ impl BrowserState { | |||
| 870 | 911 | let _ = self.backend.record_edit_history(&source_hash, &new_hash, &operation); | |
| 871 | 912 | ||
| 872 | 913 | // 3. Update VFS based on mode | |
| 873 | - | let vfs_id = self.current_vfs_id(); | |
| 914 | + | let Some(vfs_id) = self.current_vfs_id() else { | |
| 915 | + | self.status = "No VFS available".to_string(); | |
| 916 | + | return; | |
| 917 | + | }; | |
| 874 | 918 | let source_hashes = [source_hash.as_str()]; | |
| 875 | 919 | let source_nodes = self.backend.find_nodes_by_hashes(vfs_id, &source_hashes) | |
| 876 | 920 | .unwrap_or_default(); |
| @@ -15,10 +15,13 @@ impl BrowserState { | |||
| 15 | 15 | ||
| 16 | 16 | /// Reload the VFS list from the database and reset navigation to root. | |
| 17 | 17 | pub fn refresh_vfs_list(&mut self) { | |
| 18 | - | self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| { | |
| 19 | - | error!("Failed to refresh VFS list: {e}"); | |
| 20 | - | Vec::new() | |
| 21 | - | })); | |
| 18 | + | match self.backend.list_vfs() { | |
| 19 | + | Ok(list) => self.vfs_list = Arc::new(list), | |
| 20 | + | Err(e) => { | |
| 21 | + | error!("Failed to refresh VFS list: {e}"); | |
| 22 | + | return; | |
| 23 | + | } | |
| 24 | + | } | |
| 22 | 25 | if self.current_vfs_idx >= self.vfs_list.len() { | |
| 23 | 26 | self.current_vfs_idx = 0; | |
| 24 | 27 | } | |
| @@ -41,7 +44,7 @@ impl BrowserState { | |||
| 41 | 44 | ||
| 42 | 45 | /// Refresh the smart folder list for the current VFS. | |
| 43 | 46 | pub fn refresh_smart_folders(&mut self) { | |
| 44 | - | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 47 | + | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 45 | 48 | self.smart_folders = self.backend.list_smart_folders(vfs_id) | |
| 46 | 49 | .unwrap_or_else(|e| { | |
| 47 | 50 | warn!("Failed to load smart folders: {e}"); | |
| @@ -51,7 +54,7 @@ impl BrowserState { | |||
| 51 | 54 | ||
| 52 | 55 | /// Save the current search filter as a smart folder with the given name. | |
| 53 | 56 | pub fn save_smart_folder(&mut self, name: &str) { | |
| 54 | - | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 57 | + | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 55 | 58 | match self.backend.create_smart_folder(vfs_id, name, &self.search_filter) { | |
| 56 | 59 | Ok(_) => { | |
| 57 | 60 | self.status = format!("Saved smart folder: {name}"); | |
| @@ -106,7 +109,7 @@ impl BrowserState { | |||
| 106 | 109 | return; | |
| 107 | 110 | } | |
| 108 | 111 | }; | |
| 109 | - | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 112 | + | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 110 | 113 | let hash_refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); | |
| 111 | 114 | let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hash_refs) | |
| 112 | 115 | .unwrap_or_default(); | |
| @@ -130,7 +133,7 @@ impl BrowserState { | |||
| 130 | 133 | pub fn find_similar(&mut self, hash: &str) { | |
| 131 | 134 | match self.backend.find_similar(hash, 50) { | |
| 132 | 135 | Ok(results) => { | |
| 133 | - | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 136 | + | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 134 | 137 | let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect(); | |
| 135 | 138 | let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes) | |
| 136 | 139 | .unwrap_or_default(); | |
| @@ -150,7 +153,7 @@ impl BrowserState { | |||
| 150 | 153 | pub fn find_near_duplicates(&mut self, hash: &str) { | |
| 151 | 154 | match self.backend.find_near_duplicates(hash, 50) { | |
| 152 | 155 | Ok(results) => { | |
| 153 | - | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 156 | + | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 154 | 157 | let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect(); | |
| 155 | 158 | let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes) | |
| 156 | 159 | .unwrap_or_default(); |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use std::fs; | |
| 7 | 7 | use std::path::{Path, PathBuf}; | |
| 8 | 8 | use std::sync::Arc; | |
| 9 | - | use std::sync::atomic::AtomicU32; | |
| 9 | + | use std::sync::atomic::{AtomicBool, AtomicU32}; | |
| 10 | 10 | use std::time::Instant; | |
| 11 | 11 | ||
| 12 | 12 | use tracing::{error, warn}; | |
| @@ -56,6 +56,8 @@ pub struct SharedState { | |||
| 56 | 56 | pub device_sample_rate: AtomicU32, | |
| 57 | 57 | /// MIDI note events pushed by the MIDI callback, drained by the GUI each frame. | |
| 58 | 58 | pub midi_recent_notes: Mutex<Vec<MidiNoteEvent>>, | |
| 59 | + | /// Set to `true` to signal the streaming decode thread to stop. | |
| 60 | + | pub decode_cancel: AtomicBool, | |
| 59 | 61 | } | |
| 60 | 62 | ||
| 61 | 63 | impl Default for SharedState { | |
| @@ -65,6 +67,7 @@ impl Default for SharedState { | |||
| 65 | 67 | instrument: Mutex::new(InstrumentPlayback::new(8)), | |
| 66 | 68 | device_sample_rate: AtomicU32::new(44100), | |
| 67 | 69 | midi_recent_notes: Mutex::new(Vec::new()), | |
| 70 | + | decode_cancel: AtomicBool::new(false), | |
| 68 | 71 | } | |
| 69 | 72 | } | |
| 70 | 73 | } |
| @@ -3,9 +3,9 @@ use tracing::{error, warn}; | |||
| 3 | 3 | use super::*; | |
| 4 | 4 | ||
| 5 | 5 | impl BrowserState { | |
| 6 | - | /// Database ID of the currently active VFS. | |
| 7 | - | pub fn current_vfs_id(&self) -> VfsId { | |
| 8 | - | self.vfs_list[self.current_vfs_idx].id | |
| 6 | + | /// Database ID of the currently active VFS, or `None` if the list is empty. | |
| 7 | + | pub fn current_vfs_id(&self) -> Option<VfsId> { | |
| 8 | + | self.vfs_list.get(self.current_vfs_idx).map(|v| v.id) | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | /// Reload the child node list and apply current sort/search. | |
| @@ -15,7 +15,13 @@ impl BrowserState { | |||
| 15 | 15 | return; | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | let vfs_id = self.current_vfs_id(); | |
| 18 | + | let vfs_id = match self.current_vfs_id() { | |
| 19 | + | Some(id) => id, | |
| 20 | + | None => { | |
| 21 | + | self.contents = Arc::new(Vec::new()); | |
| 22 | + | return; | |
| 23 | + | } | |
| 24 | + | }; | |
| 19 | 25 | ||
| 20 | 26 | if self.search_filter.is_active() || !self.search_query.is_empty() { | |
| 21 | 27 | let mut filter = self.search_filter.clone(); |
| @@ -30,14 +30,14 @@ fn insert_fake_sample(state: &BrowserState, hash: &str) { | |||
| 30 | 30 | /// Insert a fake sample and link it into the current VFS + directory. | |
| 31 | 31 | fn add_sample_to_vfs(state: &BrowserState, hash: &str, name: &str) -> NodeId { | |
| 32 | 32 | insert_fake_sample(state, hash); | |
| 33 | - | let vfs_id = state.current_vfs_id(); | |
| 33 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 34 | 34 | let parent_id = state.current_dir; | |
| 35 | 35 | state.backend.create_sample_link(vfs_id, parent_id, name, hash).unwrap() | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | /// Create a directory in the current VFS + directory and return its ID. | |
| 39 | 39 | fn add_directory(state: &BrowserState, name: &str) -> NodeId { | |
| 40 | - | let vfs_id = state.current_vfs_id(); | |
| 40 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 41 | 41 | let parent_id = state.current_dir; | |
| 42 | 42 | state.backend.create_directory(vfs_id, parent_id, name).unwrap() | |
| 43 | 43 | } | |
| @@ -218,19 +218,20 @@ mod bulk_ops { | |||
| 218 | 218 | } | |
| 219 | 219 | ||
| 220 | 220 | #[test] | |
| 221 | - | fn undo_stack_capped_at_50() { | |
| 221 | + | fn undo_stack_capped_at_100() { | |
| 222 | 222 | let (mut state, _dir) = make_state(); | |
| 223 | - | for i in 0..60 { | |
| 223 | + | for i in 0..120 { | |
| 224 | 224 | state.undo_stack.push(UndoOp::BulkTagAdd { | |
| 225 | 225 | tag: format!("tag{i}"), | |
| 226 | 226 | hashes: vec!["h1".to_string()], | |
| 227 | 227 | }); | |
| 228 | - | // Keep the stack at 50 (mimics push_undo cap logic) | |
| 229 | - | if state.undo_stack.len() > 50 { | |
| 230 | - | state.undo_stack.remove(0); | |
| 228 | + | // Keep the stack at 100 (mimics push_undo cap logic) | |
| 229 | + | if state.undo_stack.len() > 100 { | |
| 230 | + | let excess = state.undo_stack.len() - 100; | |
| 231 | + | state.undo_stack.drain(..excess); | |
| 231 | 232 | } | |
| 232 | 233 | } | |
| 233 | - | assert_eq!(state.undo_stack.len(), 50); | |
| 234 | + | assert_eq!(state.undo_stack.len(), 100); | |
| 234 | 235 | } | |
| 235 | 236 | ||
| 236 | 237 | #[test] | |
| @@ -986,7 +987,7 @@ mod import_and_analysis { | |||
| 986 | 987 | std::fs::write(&sample_path, header).unwrap(); | |
| 987 | 988 | ||
| 988 | 989 | let hash = state.backend.import_file(&sample_path).unwrap(); | |
| 989 | - | let vfs_id = state.current_vfs_id(); | |
| 990 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 990 | 991 | state.backend.create_sample_link(vfs_id, None, "broken.wav", &hash).unwrap(); | |
| 991 | 992 | state.refresh_contents(); | |
| 992 | 993 | assert_eq!(state.contents.len(), 1); | |
| @@ -1625,7 +1626,7 @@ mod misc { | |||
| 1625 | 1626 | #[test] | |
| 1626 | 1627 | fn current_vfs_id_matches_first_vfs() { | |
| 1627 | 1628 | let (state, _dir) = make_state(); | |
| 1628 | - | assert_eq!(state.current_vfs_id(), state.vfs_list[0].id); | |
| 1629 | + | assert_eq!(state.current_vfs_id().unwrap(), state.vfs_list[0].id); | |
| 1629 | 1630 | } | |
| 1630 | 1631 | ||
| 1631 | 1632 | #[test] |
| @@ -347,6 +347,11 @@ pub enum ImportMode { | |||
| 347 | 347 | total: usize, | |
| 348 | 348 | current_name: String, | |
| 349 | 349 | }, | |
| 350 | + | Cleaning { | |
| 351 | + | completed: usize, | |
| 352 | + | total: usize, | |
| 353 | + | current_name: String, | |
| 354 | + | }, | |
| 350 | 355 | ExportComplete { | |
| 351 | 356 | total: usize, | |
| 352 | 357 | errors: Vec<(String, String)>, |