max / audiofiles
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(); |