Skip to main content

max / audiofiles

Null-safe VFS handling, decode cancellation, undo stack increase current_vfs_id() now returns Option<VfsId> to handle empty VFS lists safely. Add decode_cancel flag to SharedState for clean streaming thread lifecycle. Increase undo stack cap from 50 to 100. Add ImportMode::Cleaning variant for background orphan removal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 21:57 UTC
Commit: 0068a6722f76ed3e0547484441f24632f05dd80e
Parent: ffa6b98
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)>,