Skip to main content

max / audiofiles

56.9 KB · 1424 lines History Blame Raw
1 use std::path::{Path, PathBuf};
2
3 use tracing::{error, warn};
4
5 use super::*;
6
7 /// Count audio files in a directory (recursive). Used for the import dry-run preview.
8 fn count_audio_files(dir: &Path) -> usize {
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;
18 let mut dirs = vec![dir.to_path_buf()];
19 while let Some(d) = dirs.pop() {
20 if let Ok(entries) = std::fs::read_dir(&d) {
21 for entry in entries.flatten() {
22 let path = entry.path();
23 if path.is_dir() {
24 if !audiofiles_core::util::is_macos_metadata_dir(&path) {
25 dirs.push(path);
26 }
27 } else if audiofiles_core::util::is_audio_file(&path) {
28 count += 1;
29 if let Ok(md) = entry.metadata() {
30 bytes = bytes.saturating_add(md.len());
31 }
32 }
33 }
34 }
35 }
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,
53 }
54
55 impl BrowserState {
56 /// Quick-import a single file or directory via drag-and-drop into the current folder.
57 /// After importing, starts the analysis flow so columns (BPM, Key, etc.) get populated.
58 pub fn import_path(&mut self, path: &Path) {
59 if !path.exists() {
60 self.status = format!("Path not found: {}", path.display());
61 return;
62 }
63
64 let Some(vfs_id) = self.current_vfs_id() else {
65 self.status = "No VFS available".to_string();
66 return;
67 };
68 let parent_id = self.current_dir;
69 let mut hashes = Vec::new();
70
71 if path.is_file() {
72 match self.import_single_file(path, vfs_id, parent_id) {
73 Ok(Some(hash_ext)) => {
74 self.status = format!("Imported: {}", path.display());
75 hashes.push(hash_ext);
76 }
77 Ok(None) => self.status = format!("Imported: {}", path.display()),
78 Err(e) => self.status = format!("Error: {e}"),
79 }
80 } else if path.is_dir() {
81 let mut count = 0;
82 let mut errors = 0;
83 self.import_directory_recursive(path, vfs_id, parent_id, &mut count, &mut errors, &mut hashes);
84 self.status = format!("Imported {count} files ({errors} errors)");
85 }
86
87 self.refresh_contents();
88
89 if !hashes.is_empty() {
90 self.start_analysis_flow(hashes);
91 }
92 }
93
94 /// Import one audio file: hash into the content-addressed store, then link into the VFS.
95 /// Returns `Ok(Some((hash, ext)))` on successful import, `Ok(None)` if the file was a
96 /// duplicate (NameConflict/UNIQUE), or `Err` on failure.
97 fn import_single_file(
98 &self,
99 path: &Path,
100 vfs_id: VfsId,
101 parent_id: Option<NodeId>,
102 ) -> Result<Option<(String, String)>, Box<dyn std::error::Error>> {
103 let hash = self.backend.import_file(path)?;
104 let name = path
105 .file_name()
106 .and_then(|n| n.to_str())
107 .unwrap_or("unknown")
108 .to_string();
109 let ext = path
110 .extension()
111 .and_then(|e| e.to_str())
112 .unwrap_or("")
113 .to_string();
114
115 match self.backend.create_sample_link(vfs_id, parent_id, &name, &hash) {
116 Ok(_) => Ok(Some((hash, ext))),
117 Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => Ok(None),
118 Err(crate::backend::BackendError::Core(CoreError::Db(ref sqlite_err)))
119 if sqlite_err.to_string().contains("UNIQUE") =>
120 {
121 Ok(None)
122 }
123 Err(e) => Err(Box::new(e)),
124 }
125 }
126
127 /// Recursively import a directory tree, mirroring its folder structure in the VFS.
128 /// On NameConflict for a directory, reuses the existing directory node rather than failing.
129 ///
130 /// NOTE: A parallel implementation exists in `import.rs` (`import_directory_recursive`)
131 /// for the background import worker. That version adds progress events and cancellation.
132 /// Both share the same traversal behavior (sorted, audio-only, skipped-dir filtering).
133 /// Kept separate because the cancellation/progress channel differences make a shared
134 /// abstraction more complex than the duplication.
135 fn import_directory_recursive(
136 &self,
137 dir: &Path,
138 vfs_id: VfsId,
139 parent_id: Option<NodeId>,
140 count: &mut usize,
141 errors: &mut usize,
142 hashes: &mut Vec<(String, String)>,
143 ) {
144 let entries = match fs::read_dir(dir) {
145 Ok(e) => e,
146 Err(_) => {
147 *errors += 1;
148 return;
149 }
150 };
151
152 let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
153 paths.sort();
154
155 for path in paths {
156 if path.is_dir() {
157 if audiofiles_core::util::is_macos_metadata_dir(&path) {
158 continue;
159 }
160 let dir_name = audiofiles_core::util::get_filename(&path, "folder");
161
162 // Create the directory node, or reuse an existing one if a
163 // name conflict occurs (idempotent for re-imports).
164 let dir_node_id =
165 match self.backend.create_directory(vfs_id, parent_id, &dir_name) {
166 Ok(id) => Some(id),
167 Err(crate::backend::BackendError::Core(CoreError::NameConflict(_))) => {
168 self.backend.list_children(vfs_id, parent_id)
169 .unwrap_or_default()
170 .iter()
171 .find(|n| n.name == dir_name && n.node_type == NodeType::Directory)
172 .map(|n| n.id)
173 }
174 Err(_) => {
175 *errors += 1;
176 continue;
177 }
178 };
179
180 self.import_directory_recursive(
181 &path,
182 vfs_id,
183 dir_node_id.or(parent_id),
184 count,
185 errors,
186 hashes,
187 );
188 } else if path.is_file() && audiofiles_core::util::is_audio_file(&path) {
189 match self.import_single_file(&path, vfs_id, parent_id) {
190 Ok(Some(hash_ext)) => {
191 *count += 1;
192 hashes.push(hash_ext);
193 }
194 Ok(None) => *count += 1,
195 Err(_) => *errors += 1,
196 }
197 }
198 }
199 }
200
201 /// Begin the analysis workflow by showing the configuration screen.
202 pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) {
203 if sample_hashes.is_empty() {
204 return;
205 }
206 self.import_mode = ImportMode::ConfigureAnalysis {
207 sample_hashes,
208 config: AnalysisConfig::default(),
209 };
210 }
211
212 /// Spawn the background analysis worker and start processing samples.
213 pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) {
214 // Stash parameters so the retry button can restart analysis.
215 self.last_analysis_hashes = sample_hashes.clone();
216 self.last_analysis_config = Some(config.clone());
217
218 let total = sample_hashes.len();
219
220 self.pending_review_items.clear();
221 self.analysis_errors.clear();
222 self.operation_progress = Some(crate::state::OperationProgress::new());
223 self.import_mode = ImportMode::Analyzing {
224 completed: 0,
225 total,
226 current_name: String::new(),
227 };
228 self.status = format!("Analyzing {total} samples...");
229
230 let _ = self.backend.start_analysis(sample_hashes, config);
231 }
232
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).
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 };
242 let _ = self.backend.cancel_analysis();
243 self.pending_review_items.clear();
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 };
254 }
255
256 // --- Folder import ---
257
258 /// Quick import: choose folder → import as new vault → analyze with defaults.
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.
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) {
286 let vfs_name = source
287 .file_name()
288 .and_then(|n| n.to_str())
289 .unwrap_or("folder")
290 .to_string();
291 self.quick_import = true;
292 let strategy = ImportStrategy::NewVfs { vfs_name };
293 self.start_folder_import(source, strategy);
294 }
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
308 /// Open the import configuration modal for a dropped or selected folder.
309 pub fn show_import_options(&mut self, source: PathBuf) {
310 let source_name = source
311 .file_name()
312 .and_then(|n| n.to_str())
313 .unwrap_or("folder")
314 .to_string();
315 let available_vfs = self.backend.list_vfs().unwrap_or_else(|e| {
316 warn!("Failed to list VFS: {e}");
317 Vec::new()
318 });
319
320 // Dry-run: count audio files in the source folder
321 let audio_file_count = count_audio_files(&source);
322
323 self.import_mode = ImportMode::ConfigureImport {
324 source,
325 source_name: source_name.clone(),
326 strategy: ImportStrategy::NewVfs {
327 vfs_name: source_name.clone(),
328 },
329 available_vfs,
330 selected_merge_vfs_idx: 0,
331 new_vfs_name: source_name,
332 audio_file_count,
333 };
334 }
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
360 /// Spawn the background import worker to walk and import a folder.
361 pub fn start_folder_import(&mut self, source: PathBuf, strategy: ImportStrategy) {
362 // Stash source path so the retry button can re-open the config screen.
363 self.last_import_source = Some(source.clone());
364
365 let strategy_desc = match strategy {
366 ImportStrategy::Flat { vfs_id, parent_id } => {
367 ImportStrategyDesc::Flat { vfs_id, parent_id }
368 }
369 ImportStrategy::NewVfs { vfs_name } => ImportStrategyDesc::NewVfs { vfs_name },
370 ImportStrategy::MergeIntoVfs { vfs_id, parent_id } => {
371 ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id }
372 }
373 };
374
375 let _ = self.backend.start_import(&source, strategy_desc);
376
377 self.import_file_errors.clear();
378 self.analysis_errors.clear();
379 self.operation_progress = Some(crate::state::OperationProgress::new());
380 let loose_files = self.settings.is_loose_files;
381 self.import_mode = ImportMode::Importing {
382 total: 0,
383 completed: 0,
384 current_name: String::new(),
385 walking: true,
386 walking_count: 0,
387 total_bytes: 0,
388 loose_files,
389 };
390 }
391
392 /// Poll all backend workers for events. Returns `true` if any events were processed.
393 /// Called each frame to drain import, analysis, and export progress.
394 pub fn poll_workers(&mut self) -> bool {
395 let events = self.backend.poll_events();
396 if events.is_empty() {
397 return false;
398 }
399
400 use crate::backend::BackendEvent;
401
402 for event in events {
403 match event {
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 }
417 BackendEvent::ImportWalkComplete { total, total_bytes } => {
418 let loose_files = matches!(
419 &self.import_mode,
420 ImportMode::Importing { loose_files: true, .. }
421 );
422 self.import_mode = ImportMode::Importing {
423 total,
424 completed: 0,
425 current_name: String::new(),
426 walking: false,
427 walking_count: 0,
428 total_bytes,
429 loose_files,
430 };
431 }
432 BackendEvent::ImportProgress {
433 completed,
434 total,
435 current_name,
436 } => {
437 let (prev_bytes, loose_files) = match &self.import_mode {
438 ImportMode::Importing { total_bytes, loose_files, .. } => (*total_bytes, *loose_files),
439 _ => (0, false),
440 };
441 self.import_mode = ImportMode::Importing {
442 total,
443 completed,
444 current_name,
445 walking: false,
446 walking_count: 0,
447 total_bytes: prev_bytes,
448 loose_files,
449 };
450 }
451 BackendEvent::ImportFileError { path, error } => {
452 self.import_file_errors.push(super::ImportFileError { path, error });
453 }
454 BackendEvent::ImportComplete {
455 imported,
456 total_files: _,
457 errors,
458 duplicates,
459 folders,
460 } => {
461 self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| {
462 error!("Failed to refresh VFS list: {e}");
463 Vec::new()
464 }));
465 self.refresh_contents();
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 }
472
473 let mut parts = vec![format!("Imported {} files", imported.len())];
474 if duplicates > 0 {
475 parts.push(format!("{duplicates} duplicates skipped"));
476 }
477 if errors > 0 {
478 parts.push(format!("{errors} errors"));
479 }
480 self.status = parts.join(", ");
481
482 if !folders.is_empty() && !self.quick_import {
483 let entries = folders
484 .into_iter()
485 .map(|f| FolderTagEntry {
486 folder: ImportedFolder {
487 name: f.name,
488 samples: f.samples,
489 },
490 tag_input: String::new(),
491 })
492 .collect();
493 self.import_mode = ImportMode::TagFolders {
494 entries,
495 sample_hashes: imported,
496 };
497 } else if !imported.is_empty() {
498 if self.quick_import {
499 // Skip ConfigureAnalysis — run with defaults immediately
500 self.run_analysis(imported, AnalysisConfig::default());
501 } else {
502 self.start_analysis_flow(imported);
503 }
504 } else if self.has_import_errors() {
505 self.import_mode = ImportMode::ReviewErrors;
506 } else {
507 self.quick_import = false;
508 self.import_mode = ImportMode::None;
509 }
510 return true;
511 }
512
513 // --- Analysis events ---
514 BackendEvent::AnalysisProgress {
515 completed,
516 total,
517 current_name,
518 } => {
519 self.import_mode = ImportMode::Analyzing {
520 completed,
521 total,
522 current_name,
523 };
524 }
525 BackendEvent::AnalysisSampleDone {
526 result,
527 suggestions,
528 } => {
529 let result = *result;
530 let _ = self.backend.save_analysis(&result);
531
532 let hash = audiofiles_core::SampleHash::new(result.hash.clone());
533 let name = self.backend.sample_original_name(&hash)
534 .unwrap_or_else(|_| hash.to_string());
535
536 self.pending_review_items.push(ReviewItem {
537 hash,
538 name,
539 suggestions: suggestions
540 .into_iter()
541 .map(|s| SuggestionState {
542 accepted: true,
543 suggestion: s,
544 })
545 .collect(),
546 result,
547 });
548 }
549 BackendEvent::AnalysisSampleError { hash, error } => {
550 let name = self.backend.sample_original_name(&hash)
551 .unwrap_or_else(|_| hash.clone());
552 self.analysis_errors.push(super::AnalysisFileError { hash, name, error });
553 }
554 BackendEvent::AnalysisBatchComplete => {
555 let items = std::mem::take(&mut self.pending_review_items);
556 let error_count = self.analysis_errors.len();
557 if self.quick_import {
558 // Auto-accept all suggestions in quick mode
559 for item in &items {
560 for s in &item.suggestions {
561 let _ = self.backend.add_tag(&item.hash, &s.suggestion.tag);
562 }
563 }
564 self.quick_import = false;
565 self.refresh_contents();
566 self.refresh_vfs_list();
567 let count = items.len();
568 self.import_mode = ImportMode::None;
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 };
574 } else if items.is_empty() {
575 if self.has_import_errors() {
576 self.import_mode = ImportMode::ReviewErrors;
577 } else {
578 self.import_mode = ImportMode::None;
579 self.status = "Analysis complete, no results".to_string();
580 }
581 } else {
582 let count = items.len();
583 self.import_mode = ImportMode::ReviewSuggestions {
584 items,
585 current_idx: 0,
586 sort: crate::state::ReviewSort::ImportOrder,
587 };
588 self.status = if error_count == 0 {
589 format!("Analyzed {count} samples")
590 } else {
591 format!("Analyzed {count} samples ({error_count} errors)")
592 };
593 }
594 }
595
596 // --- Export events ---
597 BackendEvent::ExportProgress {
598 completed,
599 total,
600 current_name,
601 } => {
602 self.import_mode = ImportMode::Exporting {
603 completed,
604 total,
605 current_name,
606 };
607 }
608 BackendEvent::ExportComplete { total, errors } => {
609 let error_count = errors.len();
610 if error_count == 0 {
611 self.status = format!("Exported {total} files");
612 } else {
613 self.status =
614 format!("Exported {total} files ({error_count} errors)");
615 }
616 self.import_mode = ImportMode::ExportComplete { total, errors };
617 return true;
618 }
619
620 // --- Cleanup events ---
621 BackendEvent::CleanupProgress {
622 completed,
623 total,
624 current_name,
625 } => {
626 self.import_mode = ImportMode::Cleaning {
627 completed,
628 total,
629 current_name,
630 };
631 }
632 BackendEvent::CleanupComplete { removed, errors } => {
633 self.refresh_vfs_list();
634 if errors > 0 {
635 self.status =
636 format!("Library deleted ({removed} samples removed, {errors} errors)");
637 } else if removed > 0 {
638 self.status =
639 format!("Library deleted ({removed} samples removed)");
640 } else {
641 self.status = "Library deleted".to_string();
642 }
643 self.import_mode = ImportMode::None;
644 return true;
645 }
646
647 // --- Edit events ---
648 BackendEvent::EditStarted { hash: _ } => {
649 self.edit.in_progress = true;
650 }
651 BackendEvent::EditComplete {
652 source_hash,
653 result_path,
654 operation,
655 } => {
656 self.edit.in_progress = false;
657 self.handle_edit_complete(source_hash, result_path, operation);
658 }
659 BackendEvent::EditError { hash: _, error } => {
660 self.edit.in_progress = false;
661 self.status = format!("Edit failed: {error}");
662 }
663 }
664 }
665
666 true
667 }
668
669 /// Cancel the running folder import. Lands in the acknowledgement screen
670 /// (C-3) when there's meaningful progress to acknowledge (post-walk import
671 /// of N/M files); falls through to `None` when no files have committed yet
672 /// (walking phase, or cancel before start).
673 pub fn cancel_import(&mut self) {
674 let progress = match &self.import_mode {
675 ImportMode::Importing {
676 completed, total, walking, ..
677 } if !*walking && *total > 0 => Some((*completed, *total)),
678 _ => None,
679 };
680 let _ = self.backend.cancel_import();
681 self.refresh_contents();
682 self.status = "Import cancelled".to_string();
683 self.import_mode = match progress {
684 Some((completed, total)) => ImportMode::OperationCancelled {
685 kind: crate::state::CancelKind::Import,
686 completed,
687 total,
688 destination: None,
689 },
690 None => ImportMode::None,
691 };
692 }
693
694 /// Cancel the current import and re-open the import configuration screen so
695 /// the user can adjust settings and retry.
696 pub fn retry_import(&mut self) {
697 self.cancel_import();
698 if let Some(source) = self.last_import_source.clone() {
699 self.show_import_options(source);
700 }
701 }
702
703 /// Cancel the current analysis and restart it with the same parameters.
704 pub fn retry_analysis(&mut self) {
705 self.cancel_analysis();
706 let hashes = std::mem::take(&mut self.last_analysis_hashes);
707 if let Some(config) = self.last_analysis_config.take()
708 && !hashes.is_empty() {
709 self.run_analysis(hashes, config);
710 }
711 }
712
713 /// Apply user-entered tags to each imported folder's samples, then start analysis.
714 pub fn apply_folder_tags(&mut self) {
715 self.tag_folders_apply_all_input.clear();
716 let mode = std::mem::replace(&mut self.import_mode, ImportMode::None);
717 if let ImportMode::TagFolders {
718 entries,
719 sample_hashes,
720 } = mode
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()));
726 let mut applied = 0usize;
727 for entry in &entries {
728 if entry.tag_input.trim().is_empty() {
729 continue;
730 }
731 let tag_strs: Vec<&str> = entry
732 .tag_input
733 .split(',')
734 .map(|s| s.trim())
735 .filter(|s| !s.is_empty())
736 .collect();
737 for tag_str in &tag_strs {
738 if audiofiles_core::tags::validate_tag(tag_str).is_err() {
739 continue;
740 }
741 for (hash, _ext) in &entry.folder.samples {
742 let _ = self.backend.add_tag(hash, tag_str);
743 applied += 1;
744 }
745 }
746 }
747 self.status = format!("Applied {applied} folder tags");
748 self.refresh_all_tags();
749 self.start_analysis_flow(sample_hashes);
750 } else {
751 self.import_mode = mode;
752 }
753 }
754
755 /// Skip folder tagging and proceed directly to analysis.
756 pub fn skip_folder_tags(&mut self) {
757 self.tag_folders_apply_all_input.clear();
758 let mode = std::mem::replace(&mut self.import_mode, ImportMode::None);
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()));
763 self.start_analysis_flow(sample_hashes);
764 } else {
765 self.import_mode = mode;
766 }
767 }
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
780 /// Commit accepted tag suggestions from analysis review, then return to idle.
781 pub fn apply_accepted_suggestions(&mut self) {
782 if let ImportMode::ReviewSuggestions { ref items, .. } = self.import_mode {
783 let mut applied = 0usize;
784 for item in items {
785 for sug in &item.suggestions {
786 if sug.accepted {
787 let _ = self.backend.add_tag(&item.hash, &sug.suggestion.tag);
788 applied += 1;
789 }
790 }
791 }
792 self.status = format!("Applied {applied} tags");
793 }
794 if self.has_import_errors() {
795 self.import_mode = ImportMode::ReviewErrors;
796 } else {
797 self.import_mode = ImportMode::None;
798 }
799 self.refresh_contents();
800 self.refresh_all_tags();
801 }
802
803 /// Returns `true` if there are any accumulated import or analysis errors.
804 pub fn has_import_errors(&self) -> bool {
805 !self.import_file_errors.is_empty() || !self.analysis_errors.is_empty()
806 }
807
808 /// Dismiss all import/analysis errors and return to normal browsing.
809 pub fn dismiss_import_errors(&mut self) {
810 self.import_file_errors.clear();
811 self.analysis_errors.clear();
812 self.import_mode = ImportMode::None;
813 self.refresh_contents();
814 }
815
816 /// Remove a failed sample from the store by its index in `analysis_errors`.
817 pub fn remove_failed_sample(&mut self, index: usize) {
818 if index >= self.analysis_errors.len() {
819 return;
820 }
821 let hash = self.analysis_errors[index].hash.clone();
822 if let Err(e) = self.backend.remove_sample(&hash) {
823 warn!("Failed to remove sample {hash}: {e}");
824 return;
825 }
826 self.analysis_errors.remove(index);
827 }
828
829 /// Remove all failed samples from the store and return to normal browsing.
830 pub fn remove_all_failed_samples(&mut self) {
831 for err in self.analysis_errors.drain(..) {
832 if let Err(e) = self.backend.remove_sample(&err.hash) {
833 warn!("Failed to remove sample {}: {e}", err.hash);
834 }
835 }
836 self.import_file_errors.clear();
837 self.import_mode = ImportMode::None;
838 self.refresh_contents();
839 }
840
841 // --- Export ---
842
843 /// Begin the export workflow: collect items from the current VFS/selection and show config.
844 pub fn start_export_flow(&mut self, node_ids: Option<Vec<NodeId>>) {
845 let Some(vfs_id) = self.current_vfs_id() else {
846 self.status = "No VFS available".to_string();
847 return;
848 };
849
850 let mut items = if let Some(ids) = node_ids {
851 // Export specific selected items: collect subtrees for each
852 let mut all_items = Vec::new();
853 for id in ids {
854 let node = match self.backend.get_node(id) {
855 Ok(n) => n,
856 Err(_) => continue,
857 };
858 match node.node_type {
859 NodeType::Directory => {
860 if let Ok(sub_items) =
861 self.backend.collect_export_items(vfs_id, Some(id))
862 {
863 all_items.extend(sub_items);
864 }
865 }
866 NodeType::Sample => {
867 if let Some(hash) = &node.sample_hash {
868 let ext = self.backend.sample_extension(hash)
869 .unwrap_or_default();
870 all_items.push(audiofiles_core::export::ExportItem {
871 hash: hash.clone(),
872 ext,
873 relative_path: std::path::PathBuf::from(&node.name),
874 name: node.name.clone(),
875 bpm: None,
876 musical_key: None,
877 classification: None,
878 duration: None,
879 tags: Vec::new(),
880 source_path: None,
881 });
882 }
883 }
884 }
885 }
886 all_items
887 } else if self.active_collection.is_some() {
888 // Export collection contents
889 self.contents.iter().filter_map(|node| {
890 let hash = node.node.sample_hash.as_ref()?;
891 let ext = self.backend.sample_extension(hash).unwrap_or_default();
892 Some(audiofiles_core::export::ExportItem {
893 hash: hash.clone(),
894 ext,
895 relative_path: std::path::PathBuf::from(&node.node.name),
896 name: node.node.name.clone(),
897 bpm: node.bpm,
898 musical_key: node.musical_key.clone(),
899 classification: node.classification.clone(),
900 duration: node.duration,
901 tags: node.tags.clone(),
902 source_path: None,
903 })
904 }).collect()
905 } else {
906 // Export entire current VFS subtree
907 self.backend.collect_export_items(vfs_id, self.current_dir)
908 .unwrap_or_default()
909 };
910
911 let _ = self.backend.enrich_export_with_tags(&mut items);
912
913 if items.is_empty() {
914 self.status = "No samples to export".to_string();
915 return;
916 }
917
918 let default_dest = dirs::download_dir()
919 .or_else(dirs::home_dir)
920 .unwrap_or_else(|| std::path::PathBuf::from("."))
921 .join("audiofiles Export");
922
923 let available_profiles = self.backend.list_device_profiles().unwrap_or_default();
924
925 self.import_mode = ImportMode::ConfigureExport {
926 items,
927 config: audiofiles_core::export::ExportConfig {
928 format: audiofiles_core::export::ExportFormat::Original,
929 sample_rate: None,
930 bit_depth: None,
931 channels: audiofiles_core::export::ExportChannels::Original,
932 naming_pattern: None,
933 flatten: false,
934 metadata_sidecar: false,
935 destination: default_dest,
936 device_profile: None,
937 naming_rules: None,
938 max_file_size_bytes: None,
939 name_overrides: None,
940 },
941 available_profiles,
942 };
943 }
944
945 /// Spawn the export worker and start processing.
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());
953 let _ = self.backend.start_export(items, config);
954
955 self.import_mode = ImportMode::Exporting {
956 completed: 0,
957 total: 0,
958 current_name: String::new(),
959 };
960 }
961
962 /// Cancel the running cleanup and return to idle state.
963 pub fn cancel_cleanup(&mut self) {
964 let _ = self.backend.cancel_cleanup();
965 self.import_mode = ImportMode::None;
966 self.refresh_vfs_list();
967 self.status = "Cleanup cancelled".to_string();
968 }
969
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.
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 };
981 let _ = self.backend.cancel_export();
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 };
992 }
993
994 // --- Edit operations (floating editor window) ---
995
996 /// Open the floating sample editor for the given hash.
997 pub fn open_edit_window(&mut self, hash: &str) {
998 // Load analysis for total frames and result mode preference
999 let analysis = self.backend.get_analysis(hash).ok().flatten();
1000 let total_frames = analysis.as_ref()
1001 .map(|a| (a.duration * a.sample_rate as f64) as usize)
1002 .unwrap_or(0);
1003
1004 self.edit.hash = Some(hash.to_string());
1005 self.edit.show_window = true;
1006
1007 // Reset all params to defaults
1008 self.edit.trim_start = 0.0;
1009 self.edit.trim_end = 1.0;
1010 self.edit.total_frames = total_frames;
1011 self.edit.gain_db = 0.0;
1012 self.edit.norm_peak = true;
1013 self.edit.norm_target = -0.1;
1014 self.edit.fade_in = true;
1015 self.edit.fade_duration_ms = 100.0;
1016 self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear;
1017
1018 // Cache result mode from user_config
1019 self.edit.result_mode = match self.backend.get_config("edit_result_mode").ok().flatten().as_deref() {
1020 Some("replace") => Some(super::EditResultMode::Replace),
1021 Some("sibling") => Some(super::EditResultMode::Sibling),
1022 _ => None,
1023 };
1024 }
1025
1026 /// Close the floating sample editor.
1027 pub fn close_edit_window(&mut self) {
1028 self.edit.show_window = false;
1029 self.edit.hash = None;
1030 }
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
1053 /// Apply trim to the current edit target.
1054 pub fn apply_edit_trim(&mut self) {
1055 let hash = match &self.edit.hash {
1056 Some(h) => h.clone(),
1057 None => return,
1058 };
1059 let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize;
1060 let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize;
1061 let op = audiofiles_core::edit::EditOperation::Trim { start_frame: start, end_frame: end };
1062 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1063 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1064 self.status = format!("Edit failed: {e}");
1065 }
1066 }
1067
1068 /// Apply gain adjustment to the current edit target.
1069 pub fn apply_edit_gain(&mut self) {
1070 let hash = match &self.edit.hash {
1071 Some(h) => h.clone(),
1072 None => return,
1073 };
1074 let op = audiofiles_core::edit::EditOperation::Gain { db: self.edit.gain_db };
1075 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1076 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1077 self.status = format!("Edit failed: {e}");
1078 }
1079 }
1080
1081 /// Apply normalize to the current edit target.
1082 pub fn apply_edit_normalize(&mut self) {
1083 let hash = match &self.edit.hash {
1084 Some(h) => h.clone(),
1085 None => return,
1086 };
1087 let op = if self.edit.norm_peak {
1088 audiofiles_core::edit::EditOperation::NormalizePeak { target_db: self.edit.norm_target }
1089 } else {
1090 audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs: self.edit.norm_target }
1091 };
1092 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1093 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1094 self.status = format!("Edit failed: {e}");
1095 }
1096 }
1097
1098 /// Apply reverse to the current edit target.
1099 pub fn apply_edit_reverse(&mut self) {
1100 let hash = match &self.edit.hash {
1101 Some(h) => h.clone(),
1102 None => return,
1103 };
1104 let op = audiofiles_core::edit::EditOperation::Reverse;
1105 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1106 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1107 self.status = format!("Edit failed: {e}");
1108 }
1109 }
1110
1111 /// Apply fade to the current edit target.
1112 pub fn apply_edit_fade(&mut self) {
1113 let hash = match &self.edit.hash {
1114 Some(h) => h.clone(),
1115 None => return,
1116 };
1117 // Convert ms to frames using sample rate from analysis
1118 let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
1119 .map(|a| a.sample_rate)
1120 .unwrap_or(44100);
1121 let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize;
1122 let op = if self.edit.fade_in {
1123 audiofiles_core::edit::EditOperation::FadeIn { frames, curve: self.edit.fade_curve }
1124 } else {
1125 audiofiles_core::edit::EditOperation::FadeOut { frames, curve: self.edit.fade_curve }
1126 };
1127 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1128 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1129 self.status = format!("Edit failed: {e}");
1130 }
1131 }
1132
1133 /// Insert silence at the configured position and duration.
1134 pub fn apply_edit_insert_silence(&mut self) {
1135 let hash = match &self.edit.hash {
1136 Some(h) => h.clone(),
1137 None => return,
1138 };
1139 let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
1140 .map(|a| a.sample_rate)
1141 .unwrap_or(44100);
1142 let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize;
1143 let duration_frames = ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize;
1144 let op = audiofiles_core::edit::EditOperation::InsertSilence { start_frame, duration_frames };
1145 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1146 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1147 self.status = format!("Edit failed: {e}");
1148 }
1149 }
1150
1151 /// Remove a range of frames between the configured start and end positions.
1152 pub fn apply_edit_remove_range(&mut self) {
1153 let hash = match &self.edit.hash {
1154 Some(h) => h.clone(),
1155 None => return,
1156 };
1157 let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
1158 .map(|a| a.sample_rate)
1159 .unwrap_or(44100);
1160 let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize;
1161 let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize;
1162 if start_frame >= end_frame {
1163 self.status = "Remove range: start must be before end".to_string();
1164 return;
1165 }
1166 let op = audiofiles_core::edit::EditOperation::RemoveRange { start_frame, end_frame };
1167 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1168 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1169 self.status = format!("Edit failed: {e}");
1170 }
1171 }
1172
1173 /// Apply an edit operation to all selected samples (batch edit).
1174 /// Skips directories and samples without hashes.
1175 pub fn batch_edit(&mut self, op_factory: impl Fn(&str) -> Option<audiofiles_core::edit::EditOperation>) {
1176 let hashes = self.selected_sample_hashes();
1177 if hashes.is_empty() {
1178 self.status = "No samples selected for batch edit".to_string();
1179 return;
1180 }
1181 let mut applied = 0;
1182 let mut errors = 0;
1183 for hash in &hashes {
1184 let Some(op) = op_factory(hash) else { continue; };
1185 let ext = self.backend.sample_extension(hash).unwrap_or_default();
1186 match self.backend.start_edit(hash, &ext, op) {
1187 Ok(()) => applied += 1,
1188 Err(_) => errors += 1,
1189 }
1190 }
1191 self.status = if errors > 0 {
1192 format!("Batch edit: {applied} applied, {errors} failed")
1193 } else {
1194 format!("Batch edit: {applied} samples processed")
1195 };
1196 }
1197
1198 /// Batch normalize (peak) all selected samples.
1199 pub fn batch_normalize_peak(&mut self, target_db: f64) {
1200 self.batch_edit(|_hash| {
1201 Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db })
1202 });
1203 }
1204
1205 /// Batch normalize (LUFS) all selected samples.
1206 pub fn batch_normalize_lufs(&mut self, target_lufs: f64) {
1207 self.batch_edit(|_hash| {
1208 Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs })
1209 });
1210 }
1211
1212 /// Batch reverse all selected samples.
1213 pub fn batch_reverse(&mut self) {
1214 self.batch_edit(|_hash| {
1215 Some(audiofiles_core::edit::EditOperation::Reverse)
1216 });
1217 }
1218
1219 /// Batch apply gain to all selected samples.
1220 pub fn batch_gain(&mut self, db: f64) {
1221 self.batch_edit(|_hash| {
1222 Some(audiofiles_core::edit::EditOperation::Gain { db })
1223 });
1224 }
1225
1226 /// Save the user's preferred edit result mode.
1227 pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) {
1228 let mode_str = match mode {
1229 super::EditResultMode::Replace => "replace",
1230 super::EditResultMode::Sibling => "sibling",
1231 };
1232 let _ = self.backend.set_config("edit_result_mode", mode_str);
1233 self.edit.result_mode = Some(mode);
1234 }
1235
1236 /// Handle a completed edit: import result, update VFS, record history.
1237 fn handle_edit_complete(
1238 &mut self,
1239 source_hash: String,
1240 result_path: PathBuf,
1241 operation: audiofiles_core::edit::EditOperation,
1242 ) {
1243 let op_name = operation.display_name().to_string();
1244
1245 // Use cached result mode preference
1246 let mode = match self.edit.result_mode {
1247 Some(m) => m,
1248 None => {
1249 // No preference set — store result and show prompt
1250 self.edit.pending_result = Some(super::PendingEditResult {
1251 source_hash,
1252 result_path,
1253 operation,
1254 });
1255 self.edit.result_prompt = true;
1256 return;
1257 }
1258 };
1259
1260 self.finalize_edit(source_hash, result_path, operation, mode);
1261 self.status = format!("Edit applied — {op_name}");
1262 }
1263
1264 /// Apply the chosen edit result mode to a pending edit.
1265 pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) {
1266 if remember {
1267 self.set_edit_result_mode(mode);
1268 }
1269
1270 if let Some(pending) = self.edit.pending_result.take() {
1271 let op_name = pending.operation.display_name().to_string();
1272 self.finalize_edit(pending.source_hash, pending.result_path, pending.operation, mode);
1273 self.status = format!("Edit applied — {op_name}");
1274 }
1275 self.edit.result_prompt = false;
1276 }
1277
1278 /// Common finalization: import result file, update VFS, record history, trigger analysis.
1279 fn finalize_edit(
1280 &mut self,
1281 source_hash: String,
1282 result_path: PathBuf,
1283 operation: audiofiles_core::edit::EditOperation,
1284 mode: super::EditResultMode,
1285 ) {
1286 // 1. Import the result file into the content-addressed store. Always
1287 // clean up the temp regardless of outcome — the prior code only removed
1288 // on the success branch, leaking the temp on every import failure.
1289 // (The temp is the user's edit output; after import_file copies it
1290 // into the store, we own the canonical copy by hash.)
1291 let import_result = self.backend.import_file(&result_path);
1292 let _ = std::fs::remove_file(&result_path);
1293 let new_hash = match import_result {
1294 Ok(h) => h,
1295 Err(e) => {
1296 self.status = format!("Failed to import edit result: {e}");
1297 return;
1298 }
1299 };
1300
1301 // 2. Record in edit_history
1302 let _ = self.backend.record_edit_history(&source_hash, &new_hash, &operation);
1303
1304 // 3. Update VFS based on mode
1305 let Some(vfs_id) = self.current_vfs_id() else {
1306 self.status = "No VFS available".to_string();
1307 return;
1308 };
1309 let source_hashes = [source_hash.as_str()];
1310 let source_nodes = self.backend.find_nodes_by_hashes(vfs_id, &source_hashes)
1311 .unwrap_or_default();
1312
1313 let new_ext = self.backend.sample_extension(&new_hash).unwrap_or_else(|_| "wav".to_string());
1314
1315 // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit`
1316 // can walk them back. Captured before deletion in Replace mode and
1317 // after creation in Sibling mode.
1318 let mut replace_targets: Vec<(Option<audiofiles_core::NodeId>, String)> = Vec::new();
1319 let mut sibling_node_id: Option<audiofiles_core::NodeId> = None;
1320
1321 match mode {
1322 super::EditResultMode::Replace => {
1323 // Update each VFS node that references the source hash
1324 for node in &source_nodes {
1325 // Delete old node and create new one with same name/parent
1326 let parent_id = node.node.parent_id;
1327 let name = node.node.name.clone();
1328 replace_targets.push((parent_id, name.clone()));
1329 let _ = self.backend.delete_node(node.node.id);
1330 let _ = self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash);
1331 }
1332 }
1333 super::EditResultMode::Sibling => {
1334 // Create a sibling node next to the original
1335 if let Some(node) = source_nodes.first() {
1336 let parent_id = node.node.parent_id;
1337 let (stem, _ext) = split_name_ext(&node.node.name);
1338 let sibling_name = format!("{stem}_edited.{new_ext}");
1339 if let Ok(id) = self
1340 .backend
1341 .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash)
1342 {
1343 sibling_node_id = Some(id);
1344 }
1345 }
1346 }
1347 }
1348
1349 // C-1 part 2: stash the entry only if there's actually something to
1350 // undo (no VFS nodes → nothing happened that needs reversing).
1351 if !replace_targets.is_empty() || sibling_node_id.is_some() {
1352 self.edit.last_undo = Some(super::EditUndoEntry {
1353 op_name: operation.display_name().to_string(),
1354 source_hash: source_hash.clone(),
1355 result_hash: new_hash.clone(),
1356 mode,
1357 vfs_id,
1358 replace_targets,
1359 sibling_node_id,
1360 created_at: std::time::Instant::now(),
1361 });
1362 }
1363
1364 // 4. Trigger analysis on the new sample
1365 let hashes = vec![(new_hash, new_ext)];
1366 let _ = self.backend.start_analysis(hashes, AnalysisConfig::default());
1367
1368 // 5. Refresh the file list
1369 self.refresh_contents();
1370 }
1371
1372 /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks
1373 /// the VFS nodes back to the source hash; Sibling mode deletes the
1374 /// created sibling. The original sample blob is preserved by the
1375 /// content-addressed store so no audio data needs to be restored.
1376 ///
1377 /// Returns silently with no-op if `last_undo` is empty.
1378 pub fn undo_last_edit(&mut self) {
1379 let Some(entry) = self.edit.last_undo.take() else {
1380 return;
1381 };
1382
1383 match entry.mode {
1384 super::EditResultMode::Replace => {
1385 // Re-find any nodes now pointing at result_hash and clear them
1386 // first — the edit may have produced multiple nodes when the
1387 // sample appeared in multiple places, and we recreate from the
1388 // captured (parent_id, name) list.
1389 let result_hashes = [entry.result_hash.as_str()];
1390 if let Ok(result_nodes) = self
1391 .backend
1392 .find_nodes_by_hashes(entry.vfs_id, &result_hashes)
1393 {
1394 for node in &result_nodes {
1395 let _ = self.backend.delete_node(node.node.id);
1396 }
1397 }
1398 for (parent_id, name) in &entry.replace_targets {
1399 let _ = self.backend.create_sample_link(
1400 entry.vfs_id,
1401 *parent_id,
1402 name,
1403 &entry.source_hash,
1404 );
1405 }
1406 }
1407 super::EditResultMode::Sibling => {
1408 if let Some(id) = entry.sibling_node_id {
1409 let _ = self.backend.delete_node(id);
1410 }
1411 }
1412 }
1413
1414 // Drop the matching edit_history row so the audit trail reflects the
1415 // undo. Best-effort — the UI undo affordance already disappeared.
1416 let _ = self
1417 .backend
1418 .delete_edit_history(&entry.source_hash, &entry.result_hash);
1419
1420 self.status = format!("Reverted {}", entry.op_name);
1421 self.refresh_contents();
1422 }
1423 }
1424