Skip to main content

max / audiofiles

UI: repaint guard for all workers, fix import flag leaks and bulk-op reporting - Repaint-while-busy guard now covers the forge, loose-files, and similarity workers (was classifier-only), so their single terminal event drains without waiting for a mouse move and spinner-less overlays can't look stuck. Added a loose_files_busy flag; clear latest_similarity_request on result/error so the guard stops (and a failed search is terminal). - cancel_import clears quick_import; cancel_analysis clears quick_import + backfill_in_progress and flushes buffered analysis saves. Previously these reset only on the happy path and leaked into the next import. - Bulk move/rename/delete and add-to-collection/copy-tags now count actual per-item successes and report failures instead of asserting the full count; bulk rename surfaces apply-time name collisions. Undo snapshots cover only rows that changed. - Forge apply (chop/conform) guards on busy so a second op can't orphan the first's staged files; clear current_dir on collection/similarity views (no parent ".." row); report skipped invalid folder tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 01:13 UTC
Commit: 01305c0d43b9b29bb64dd96a403cc8ec4c7478af
Parent: 87d3b7e
8 files changed, +171 insertions, -33 deletions
@@ -25,9 +25,15 @@ pub fn draw_browser(
25 25 if state.poll_workers() {
26 26 ctx.request_repaint();
27 27 }
28 - // Keep repainting while a background classifier job runs (no input would
29 - // otherwise repaint, so its completion event would sit unread).
30 - if state.classifier.busy.is_some() {
28 + // Keep repainting while any background worker runs: egui sleeps after input
29 + // settles, so without this a worker's completion event sits in the channel
30 + // unread (and a spinner-less overlay looks stuck) until the next mouse move.
31 + // Every recv-loop worker that reports a single terminal event needs this.
32 + if state.classifier.busy.is_some()
33 + || state.forge.busy
34 + || state.loose_files_busy
35 + || state.latest_similarity_request.is_some()
36 + {
31 37 ctx.request_repaint();
32 38 }
33 39
@@ -431,19 +431,31 @@ impl BrowserState {
431 431
432 432 let target_parent = selected_idx.map(|idx| directories[idx].0);
433 433
434 + // Record undo + count only the moves that actually succeeded; a node the
435 + // library mutated underneath (deleted by a worker, name collision) must
436 + // not be reported as moved or covered by the undo snapshot.
434 437 let mut moves = Vec::new();
438 + let mut failed = 0usize;
435 439 for &node_id in &node_ids {
436 - if let Ok(node) = self.backend.get_node(node_id) {
437 - moves.push((node_id, node.parent_id));
438 - let _ = self.backend.move_node(node_id, target_parent);
440 + match self.backend.get_node(node_id) {
441 + Ok(node) => match self.backend.move_node(node_id, target_parent) {
442 + Ok(()) => moves.push((node_id, node.parent_id)),
443 + Err(_) => failed += 1,
444 + },
445 + Err(_) => failed += 1,
439 446 }
440 447 }
441 448
449 + let moved = moves.len();
442 450 self.push_undo(UndoOp::BulkMove { moves });
443 451 let dest_name = selected_idx
444 452 .map(|idx| directories[idx].1.as_str())
445 453 .unwrap_or("root");
446 - self.status = format!("Moved {} items to {}", node_ids.len(), dest_name);
454 + self.status = if failed == 0 {
455 + format!("Moved {moved} items to {dest_name}")
456 + } else {
457 + format!("Moved {moved} items to {dest_name} ({failed} failed)")
458 + };
447 459 self.bulk_modal = None;
448 460 self.refresh_contents();
449 461 }
@@ -482,25 +494,36 @@ impl BrowserState {
482 494 .collect::<Vec<_>>(),
483 495 );
484 496
497 + // Apply each rename and count only the successes. `rename_node` re-checks
498 + // uniqueness against the live tree, so a name that collides with a sibling
499 + // that appeared after the modal opened fails here and is reported as such,
500 + // rather than being silently dropped (the no-apply-time-revalidation gap).
485 501 let mut renames = Vec::new();
502 + let mut failed = 0usize;
486 503 for (target, new_stem) in targets.iter().zip(new_stems.iter()) {
487 - if let Ok(node) = self.backend.get_node(target.node_id) {
488 - let new_name = if target.context.extension.is_empty() {
489 - new_stem.clone()
490 - } else {
491 - format!("{}.{}", new_stem, target.context.extension)
492 - };
493 - renames.push((target.node_id, node.name.clone()));
494 - let _ = self.backend.rename_node(target.node_id, &new_name);
504 + match self.backend.get_node(target.node_id) {
505 + Ok(node) => {
506 + let new_name = if target.context.extension.is_empty() {
507 + new_stem.clone()
508 + } else {
509 + format!("{}.{}", new_stem, target.context.extension)
510 + };
511 + match self.backend.rename_node(target.node_id, &new_name) {
512 + Ok(()) => renames.push((target.node_id, node.name.clone())),
513 + Err(_) => failed += 1,
514 + }
515 + }
516 + Err(_) => failed += 1,
495 517 }
496 518 }
497 519
520 + let renamed = renames.len();
498 521 self.push_undo(UndoOp::BulkRename { renames });
499 - self.status = format!(
500 - "Renamed {} items (pattern: {})",
501 - new_stems.len(),
502 - pattern_input
503 - );
522 + self.status = if failed == 0 {
523 + format!("Renamed {renamed} items (pattern: {pattern_input})")
524 + } else {
525 + format!("Renamed {renamed} items ({failed} failed, e.g. name collision)")
526 + };
504 527 self.bulk_modal = None;
505 528 self.refresh_contents();
506 529 }
@@ -530,16 +553,25 @@ impl BrowserState {
530 553 }
531 554 }
532 555
533 - // Delete
556 + // Delete, counting only the rows that actually deleted.
557 + let mut deleted = 0usize;
558 + let mut failed = 0usize;
534 559 for &node_id in &node_ids {
535 - let _ = self.backend.delete_node(node_id);
560 + match self.backend.delete_node(node_id) {
561 + Ok(()) => deleted += 1,
562 + Err(_) => failed += 1,
563 + }
536 564 }
537 565
538 566 self.push_undo(UndoOp::BulkDelete {
539 567 nodes: all_nodes,
540 568 tags: all_tags,
541 569 });
542 - self.status = format!("Deleted {} items", node_ids.len());
570 + self.status = if failed == 0 {
571 + format!("Deleted {deleted} items")
572 + } else {
573 + format!("Deleted {deleted} items ({failed} failed)")
574 + };
543 575 self.refresh_contents();
544 576 }
545 577
@@ -97,6 +97,13 @@ impl BrowserState {
97 97 /// and returns immediately so the UI keeps painting; the result lands via
98 98 /// `BackendEvent::ForgeChopComplete` in `poll_workers`.
99 99 pub fn forge_apply_chop(&mut self) {
100 + // One forge apply at a time: starting a second while one is in flight
101 + // would overwrite the single pending-destination slot, so the first op's
102 + // staged files would be orphaned. The preview path stays ungated so it
103 + // keeps updating as the user drags slice controls.
104 + if self.forge.busy {
105 + return;
106 + }
100 107 let Some(hash) = self.forge.hash.clone() else { return };
101 108 let Some(vfs_id) = self.current_vfs_id() else {
102 109 self.status = "No VFS available".to_string();
@@ -121,6 +128,10 @@ impl BrowserState {
121 128 /// Conform the loaded sample to the selected device's format, writing a new
122 129 /// sibling sample.
123 130 pub fn forge_conform_device(&mut self, device_name: &str) {
131 + // One forge apply at a time (see forge_apply_chop).
132 + if self.forge.busy {
133 + return;
134 + }
124 135 let Some(hash) = self.forge.hash.clone() else { return };
125 136 let Some(vfs_id) = self.current_vfs_id() else {
126 137 self.status = "No VFS available".to_string();
@@ -283,6 +283,13 @@ impl BrowserState {
283 283 _ => None,
284 284 };
285 285 let _ = self.backend.cancel_analysis();
286 + // Persist whatever finished before the cancel landed (matches the old
287 + // save-per-result behavior), then clear the in-flight flags so a later
288 + // import isn't silently treated as quick/backfill (they only reset on the
289 + // happy path otherwise — the flag-leak finding).
290 + self.flush_analysis_saves();
291 + self.quick_import = false;
292 + self.backfill_in_progress = false;
286 293 self.pending_review_items.clear();
287 294 self.status = "Analysis cancelled".to_string();
288 295 self.import_mode = match progress {
@@ -728,6 +735,7 @@ impl BrowserState {
728 735
729 736 // --- Loose-files maintenance events ---
730 737 BackendEvent::LooseFilesIntegrity { missing } => {
738 + self.loose_files_busy = false;
731 739 self.loose_files_missing_count = missing;
732 740 if missing > 0 {
733 741 self.show_loose_files_warning = true;
@@ -737,6 +745,7 @@ impl BrowserState {
737 745 relocated,
738 746 still_missing,
739 747 } => {
748 + self.loose_files_busy = false;
740 749 self.loose_files_missing_count = still_missing;
741 750 self.status = if relocated == 0 {
742 751 "No matching files found in that folder.".to_string()
@@ -751,12 +760,14 @@ impl BrowserState {
751 760 self.refresh_contents();
752 761 }
753 762 BackendEvent::LooseFilesPurged { purged } => {
763 + self.loose_files_busy = false;
754 764 self.status = format!("Purged {purged} missing samples");
755 765 self.loose_files_missing_count = 0;
756 766 self.show_loose_files_warning = false;
757 767 self.refresh_contents();
758 768 }
759 769 BackendEvent::LooseFilesFailed { message } => {
770 + self.loose_files_busy = false;
760 771 self.status = format!("Loose-files operation failed: {message}");
761 772 }
762 773
@@ -839,6 +850,9 @@ impl BrowserState {
839 850 self.apply_similarity_results(&source, &refs, "near-duplicates", true);
840 851 }
841 852 BackendEvent::SearchError { error } => {
853 + // Clear the in-flight request so the repaint-while-busy guard
854 + // stops; a failed search is terminal just like a result.
855 + self.latest_similarity_request = None;
842 856 self.status = format!("Search failed: {error}");
843 857 }
844 858
@@ -872,6 +886,10 @@ impl BrowserState {
872 886 _ => None,
873 887 };
874 888 let _ = self.backend.cancel_import();
889 + // Clear the quick-import flag: it's set when a quick import starts and
890 + // otherwise only resets on completion, so a cancel mid-import would leak
891 + // it into the next (normal) import and skip its tagging/config steps.
892 + self.quick_import = false;
875 893 self.refresh_contents();
876 894 self.status = "Import cancelled".to_string();
877 895 self.import_mode = match progress {
@@ -918,6 +936,7 @@ impl BrowserState {
918 936 // verbatim — re-applying via add_tag is safe (INSERT OR IGNORE).
919 937 self.last_folder_tags = Some((entries.clone(), sample_hashes.clone()));
920 938 let mut applied = 0usize;
939 + let mut skipped = 0usize;
921 940 for entry in &entries {
922 941 if entry.tag_input.trim().is_empty() {
923 942 continue;
@@ -930,6 +949,9 @@ impl BrowserState {
930 949 .collect();
931 950 for tag_str in &tag_strs {
932 951 if audiofiles_core::tags::validate_tag(tag_str).is_err() {
952 + // Don't silently drop bad tags — count them so the status
953 + // tells the user some input wasn't applied.
954 + skipped += 1;
933 955 continue;
934 956 }
935 957 for (hash, _ext) in &entry.folder.samples {
@@ -938,7 +960,11 @@ impl BrowserState {
938 960 }
939 961 }
940 962 }
941 - self.status = format!("Applied {applied} folder tags");
963 + self.status = if skipped == 0 {
964 + format!("Applied {applied} folder tags")
965 + } else {
966 + format!("Applied {applied} folder tags ({skipped} invalid skipped)")
967 + };
942 968 self.refresh_all_tags();
943 969 self.start_analysis_flow(sample_hashes);
944 970 } else {
@@ -42,8 +42,9 @@ impl BrowserState {
42 42 }
43 43 // Runs off the GUI thread: it stats every loose source path. The result
44 44 // arrives as BackendEvent::LooseFilesIntegrity (see poll_workers).
45 - if let Err(e) = self.backend.start_loose_files_check() {
46 - warn!("Loose-files integrity check failed to start: {e}");
45 + match self.backend.start_loose_files_check() {
46 + Ok(()) => self.loose_files_busy = true,
47 + Err(e) => warn!("Loose-files integrity check failed to start: {e}"),
47 48 }
48 49 }
49 50
@@ -51,7 +52,10 @@ impl BrowserState {
51 52 /// Runs in the background; the VFS listing refreshes when the result lands.
52 53 pub fn purge_missing_loose_files(&mut self) {
53 54 match self.backend.start_loose_files_purge() {
54 - Ok(()) => self.status = "Purging missing samples\u{2026}".to_string(),
55 + Ok(()) => {
56 + self.loose_files_busy = true;
57 + self.status = "Purging missing samples\u{2026}".to_string();
58 + }
55 59 Err(e) => self.status = format!("Purge failed: {e}"),
56 60 }
57 61 }
@@ -70,7 +74,10 @@ impl BrowserState {
70 74 // candidates, which is unbounded. The result arrives as
71 75 // BackendEvent::LooseFilesRelocated (see poll_workers).
72 76 match self.backend.start_loose_files_relocate(&search_root) {
73 - Ok(()) => self.status = "Searching that folder for missing samples\u{2026}".to_string(),
77 + Ok(()) => {
78 + self.loose_files_busy = true;
79 + self.status = "Searching that folder for missing samples\u{2026}".to_string();
80 + }
74 81 Err(e) => self.status = format!("Could not start locate \u{2014} {e}"),
75 82 }
76 83 }
@@ -211,6 +218,7 @@ impl BrowserState {
211 218 /// Activate a dynamic collection: apply its filter and refresh.
212 219 pub fn activate_dynamic_collection(&mut self, _id: CollectionId, filter: &SearchFilter) {
213 220 self.active_collection = None; // not a manual-collection view
221 + self.current_dir = None; // a filter view has no parent ".." row
214 222 self.search_filter = filter.clone();
215 223 self.search_query = filter.text_query.clone();
216 224 self.similarity_search_hash = None;
@@ -246,6 +254,7 @@ impl BrowserState {
246 254 let count = nodes.len();
247 255 self.contents = Arc::new(nodes);
248 256 self.active_collection = Some(id);
257 + self.current_dir = None; // a collection view has no parent ".." row
249 258 self.similarity_search_hash = None;
250 259 self.similarity_source_name = None;
251 260 self.selection.clear();
@@ -313,6 +322,12 @@ impl BrowserState {
313 322 .unwrap_or_default();
314 323 let count = nodes.len();
315 324 self.contents = Arc::new(nodes);
325 + // The results replace the folder view, so there is no parent ".." row;
326 + // clear current_dir to keep the row-offset bookkeeping honest.
327 + self.current_dir = None;
328 + // The in-flight request resolved — clear it so the repaint-while-busy
329 + // guard stops and a later search starts from a clean slate.
330 + self.latest_similarity_request = None;
316 331 self.similarity_search_hash = Some(source_hash.to_string());
317 332 self.similarity_source_name = self.backend.sample_original_name(source_hash).ok();
318 333 self.selection.clear();
@@ -353,6 +353,10 @@ pub struct BrowserState {
353 353 pub loose_files_missing_count: usize,
354 354 /// Whether to show the integrity warning overlay.
355 355 pub show_loose_files_warning: bool,
356 + /// True while a loose-files maintenance op (check/relocate/purge) runs on its
357 + /// worker. Drives a repaint guard so the terminal event is drained without
358 + /// waiting for input, and a spinner-less overlay can't appear stuck.
359 + pub loose_files_busy: bool,
356 360 }
357 361
358 362 impl BrowserState {
@@ -589,6 +593,7 @@ impl BrowserState {
589 593 classifier: ClassifierUiState::default(),
590 594 loose_files_missing_count: 0,
591 595 show_loose_files_warning: false,
596 + loose_files_busy: false,
592 597 })
593 598 }
594 599
@@ -1925,6 +1925,30 @@ mod misc {
1925 1925 }
1926 1926
1927 1927 #[test]
1928 + fn cancel_import_clears_quick_import_flag() {
1929 + // Regression: quick_import is set when a quick import starts and was only
1930 + // cleared on the happy path, so a cancel mid-import leaked it into the
1931 + // next normal import and skipped its tagging/config steps.
1932 + let (mut state, _dir) = make_state();
1933 + state.quick_import = true;
1934 + state.cancel_import();
1935 + assert!(!state.quick_import, "cancel_import must clear quick_import");
1936 + }
1937 +
1938 + #[test]
1939 + fn cancel_analysis_clears_flow_flags() {
1940 + let (mut state, _dir) = make_state();
1941 + state.quick_import = true;
1942 + state.backfill_in_progress = true;
1943 + state.cancel_analysis();
1944 + assert!(!state.quick_import, "cancel_analysis must clear quick_import");
1945 + assert!(
1946 + !state.backfill_in_progress,
1947 + "cancel_analysis must clear backfill_in_progress"
1948 + );
1949 + }
1950 +
1951 + #[test]
1928 1952 fn classifier_create_rule_applies_tag() {
1929 1953 let (mut state, _dir) = make_state();
1930 1954 insert_fake_sample(&state, "aaa111");
@@ -265,13 +265,24 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
265 265 for coll in &collections {
266 266 if ui.button(&coll.name).clicked() {
267 267 let nodes = state.selected_nodes();
268 + // Count only samples actually added — directories (no hash)
269 + // are skipped, so reporting nodes.len() over-counts.
270 + let mut added = 0usize;
271 + let mut failed = 0usize;
268 272 for n in &nodes {
269 273 if let Some(hash) = &n.node.sample_hash {
270 - let _ = state.backend.add_to_collection(coll.id, hash);
274 + match state.backend.add_to_collection(coll.id, hash) {
275 + Ok(_) => added += 1,
276 + Err(_) => failed += 1,
277 + }
271 278 }
272 279 }
273 280 state.refresh_collections();
274 - state.status = format!("Added {} items to {}", nodes.len(), coll.name);
281 + state.status = if failed == 0 {
282 + format!("Added {added} items to {}", coll.name)
283 + } else {
284 + format!("Added {added} items to {} ({failed} failed)", coll.name)
285 + };
275 286 ui.close();
276 287 }
277 288 }
@@ -335,14 +346,22 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
335 346 if let Ok(src_tags) = state.backend.get_sample_tags(&src_hash) {
336 347 let target_hashes = state.selected_sample_hashes();
337 348 let mut applied = 0;
349 + let mut failed = 0usize;
338 350 for hash in &target_hashes {
339 351 if *hash == src_hash_str { continue; }
352 + let mut ok = true;
340 353 for tag in &src_tags {
341 - let _ = state.backend.add_tag(hash, tag);
354 + if state.backend.add_tag(hash, tag).is_err() {
355 + ok = false;
356 + }
342 357 }
343 - applied += 1;
358 + if ok { applied += 1; } else { failed += 1; }
344 359 }
345 - state.status = format!("Copied {} tags to {} samples", src_tags.len(), applied);
360 + state.status = if failed == 0 {
361 + format!("Copied {} tags to {applied} samples", src_tags.len())
362 + } else {
363 + format!("Copied {} tags to {applied} samples ({failed} failed)", src_tags.len())
364 + };
346 365 state.refresh_selected_tags();
347 366 }
348 367 ui.close();