Skip to main content

max / audiofiles

56.6 KB · 1422 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 if !hashes.is_empty() {
709 self.run_analysis(hashes, config);
710 }
711 }
712 }
713
714 /// Apply user-entered tags to each imported folder's samples, then start analysis.
715 pub fn apply_folder_tags(&mut self) {
716 self.tag_folders_apply_all_input.clear();
717 let mode = std::mem::replace(&mut self.import_mode, ImportMode::None);
718 if let ImportMode::TagFolders {
719 entries,
720 sample_hashes,
721 } = mode
722 {
723 // Stash entries + hashes so the Back button on ConfigureAnalysis
724 // (C-1) can rehydrate this screen. tag_input strings are preserved
725 // verbatim — re-applying via add_tag is safe (INSERT OR IGNORE).
726 self.last_folder_tags = Some((entries.clone(), sample_hashes.clone()));
727 let mut applied = 0usize;
728 for entry in &entries {
729 if entry.tag_input.trim().is_empty() {
730 continue;
731 }
732 let tag_strs: Vec<&str> = entry
733 .tag_input
734 .split(',')
735 .map(|s| s.trim())
736 .filter(|s| !s.is_empty())
737 .collect();
738 for tag_str in &tag_strs {
739 if audiofiles_core::tags::validate_tag(tag_str).is_err() {
740 continue;
741 }
742 for (hash, _ext) in &entry.folder.samples {
743 let _ = self.backend.add_tag(hash, tag_str);
744 applied += 1;
745 }
746 }
747 }
748 self.status = format!("Applied {applied} folder tags");
749 self.refresh_all_tags();
750 self.start_analysis_flow(sample_hashes);
751 } else {
752 self.import_mode = mode;
753 }
754 }
755
756 /// Skip folder tagging and proceed directly to analysis.
757 pub fn skip_folder_tags(&mut self) {
758 self.tag_folders_apply_all_input.clear();
759 let mode = std::mem::replace(&mut self.import_mode, ImportMode::None);
760 if let ImportMode::TagFolders { entries, sample_hashes } = mode {
761 // Stash entries so Back from ConfigureAnalysis (C-1) can return the
762 // user to the tagging screen even when they skipped initially.
763 self.last_folder_tags = Some((entries.clone(), sample_hashes.clone()));
764 self.start_analysis_flow(sample_hashes);
765 } else {
766 self.import_mode = mode;
767 }
768 }
769
770 /// Restore the most recently-shown TagFolders screen from the stash. Called
771 /// from the Back button on ConfigureAnalysis. No-op if nothing's stashed.
772 pub fn back_to_tag_folders(&mut self) {
773 if let Some((entries, sample_hashes)) = self.last_folder_tags.take() {
774 self.import_mode = ImportMode::TagFolders {
775 entries,
776 sample_hashes,
777 };
778 }
779 }
780
781 /// Commit accepted tag suggestions from analysis review, then return to idle.
782 pub fn apply_accepted_suggestions(&mut self) {
783 if let ImportMode::ReviewSuggestions { ref items, .. } = self.import_mode {
784 let mut applied = 0usize;
785 for item in items {
786 for sug in &item.suggestions {
787 if sug.accepted {
788 let _ = self.backend.add_tag(&item.hash, &sug.suggestion.tag);
789 applied += 1;
790 }
791 }
792 }
793 self.status = format!("Applied {applied} tags");
794 }
795 if self.has_import_errors() {
796 self.import_mode = ImportMode::ReviewErrors;
797 } else {
798 self.import_mode = ImportMode::None;
799 }
800 self.refresh_contents();
801 self.refresh_all_tags();
802 }
803
804 /// Returns `true` if there are any accumulated import or analysis errors.
805 pub fn has_import_errors(&self) -> bool {
806 !self.import_file_errors.is_empty() || !self.analysis_errors.is_empty()
807 }
808
809 /// Dismiss all import/analysis errors and return to normal browsing.
810 pub fn dismiss_import_errors(&mut self) {
811 self.import_file_errors.clear();
812 self.analysis_errors.clear();
813 self.import_mode = ImportMode::None;
814 self.refresh_contents();
815 }
816
817 /// Remove a failed sample from the store by its index in `analysis_errors`.
818 pub fn remove_failed_sample(&mut self, index: usize) {
819 if index >= self.analysis_errors.len() {
820 return;
821 }
822 let hash = self.analysis_errors[index].hash.clone();
823 if let Err(e) = self.backend.remove_sample(&hash) {
824 warn!("Failed to remove sample {hash}: {e}");
825 return;
826 }
827 self.analysis_errors.remove(index);
828 }
829
830 /// Remove all failed samples from the store and return to normal browsing.
831 pub fn remove_all_failed_samples(&mut self) {
832 for err in self.analysis_errors.drain(..) {
833 if let Err(e) = self.backend.remove_sample(&err.hash) {
834 warn!("Failed to remove sample {}: {e}", err.hash);
835 }
836 }
837 self.import_file_errors.clear();
838 self.import_mode = ImportMode::None;
839 self.refresh_contents();
840 }
841
842 // --- Export ---
843
844 /// Begin the export workflow: collect items from the current VFS/selection and show config.
845 pub fn start_export_flow(&mut self, node_ids: Option<Vec<NodeId>>) {
846 let Some(vfs_id) = self.current_vfs_id() else {
847 self.status = "No VFS available".to_string();
848 return;
849 };
850
851 let mut items = if let Some(ids) = node_ids {
852 // Export specific selected items: collect subtrees for each
853 let mut all_items = Vec::new();
854 for id in ids {
855 let node = match self.backend.get_node(id) {
856 Ok(n) => n,
857 Err(_) => continue,
858 };
859 match node.node_type {
860 NodeType::Directory => {
861 if let Ok(sub_items) =
862 self.backend.collect_export_items(vfs_id, Some(id))
863 {
864 all_items.extend(sub_items);
865 }
866 }
867 NodeType::Sample => {
868 if let Some(hash) = &node.sample_hash {
869 let ext = self.backend.sample_extension(hash)
870 .unwrap_or_default();
871 all_items.push(audiofiles_core::export::ExportItem {
872 hash: hash.clone(),
873 ext,
874 relative_path: std::path::PathBuf::from(&node.name),
875 name: node.name.clone(),
876 bpm: None,
877 musical_key: None,
878 classification: None,
879 duration: None,
880 tags: Vec::new(),
881 source_path: None,
882 });
883 }
884 }
885 }
886 }
887 all_items
888 } else if self.active_collection.is_some() {
889 // Export collection contents
890 self.contents.iter().filter_map(|node| {
891 let hash = node.node.sample_hash.as_ref()?;
892 let ext = self.backend.sample_extension(hash).unwrap_or_default();
893 Some(audiofiles_core::export::ExportItem {
894 hash: hash.clone(),
895 ext,
896 relative_path: std::path::PathBuf::from(&node.node.name),
897 name: node.node.name.clone(),
898 bpm: node.bpm,
899 musical_key: node.musical_key.clone(),
900 classification: node.classification.clone(),
901 duration: node.duration,
902 tags: node.tags.clone(),
903 source_path: None,
904 })
905 }).collect()
906 } else {
907 // Export entire current VFS subtree
908 self.backend.collect_export_items(vfs_id, self.current_dir)
909 .unwrap_or_default()
910 };
911
912 let _ = self.backend.enrich_export_with_tags(&mut items);
913
914 if items.is_empty() {
915 self.status = "No samples to export".to_string();
916 return;
917 }
918
919 let default_dest = dirs::download_dir()
920 .or_else(dirs::home_dir)
921 .unwrap_or_else(|| std::path::PathBuf::from("."))
922 .join("audiofiles Export");
923
924 let available_profiles = self.backend.list_device_profiles().unwrap_or_default();
925
926 self.import_mode = ImportMode::ConfigureExport {
927 items,
928 config: audiofiles_core::export::ExportConfig {
929 format: audiofiles_core::export::ExportFormat::Original,
930 sample_rate: None,
931 bit_depth: None,
932 channels: audiofiles_core::export::ExportChannels::Original,
933 naming_pattern: None,
934 flatten: false,
935 metadata_sidecar: false,
936 destination: default_dest,
937 device_profile: None,
938 naming_rules: None,
939 max_file_size_bytes: None,
940 name_overrides: None,
941 },
942 available_profiles,
943 };
944 }
945
946 /// Spawn the export worker and start processing.
947 pub fn run_export(&mut self, items: Vec<audiofiles_core::export::ExportItem>, config: audiofiles_core::export::ExportConfig) {
948 // Stash destination so the cancel-acknowledgement screen can name it
949 // if the user cancels mid-export (C-3). The Exporting variant doesn't
950 // carry destination — it's consumed by the worker — but the path is
951 // still useful in the UI for "files already written remain at <path>".
952 self.last_export_destination = Some(config.destination.clone());
953 self.operation_progress = Some(crate::state::OperationProgress::new());
954 let _ = self.backend.start_export(items, config);
955
956 self.import_mode = ImportMode::Exporting {
957 completed: 0,
958 total: 0,
959 current_name: String::new(),
960 };
961 }
962
963 /// Cancel the running cleanup and return to idle state.
964 pub fn cancel_cleanup(&mut self) {
965 let _ = self.backend.cancel_cleanup();
966 self.import_mode = ImportMode::None;
967 self.refresh_vfs_list();
968 self.status = "Cleanup cancelled".to_string();
969 }
970
971 /// Cancel the running export. Lands in the acknowledgement screen (C-3)
972 /// when meaningful progress has happened, surfacing the destination folder
973 /// so the user knows where partial files may sit. Falls through to `None`
974 /// when no items have been written yet.
975 pub fn cancel_export(&mut self) {
976 let progress = match &self.import_mode {
977 ImportMode::Exporting { completed, total, .. } if *total > 0 => {
978 Some((*completed, *total))
979 }
980 _ => None,
981 };
982 let _ = self.backend.cancel_export();
983 self.status = "Export cancelled".to_string();
984 self.import_mode = match progress {
985 Some((completed, total)) => ImportMode::OperationCancelled {
986 kind: crate::state::CancelKind::Export,
987 completed,
988 total,
989 destination: self.last_export_destination.clone(),
990 },
991 None => ImportMode::None,
992 };
993 }
994
995 // --- Edit operations (floating editor window) ---
996
997 /// Open the floating sample editor for the given hash.
998 pub fn open_edit_window(&mut self, hash: &str) {
999 // Load analysis for total frames and result mode preference
1000 let analysis = self.backend.get_analysis(hash).ok().flatten();
1001 let total_frames = analysis.as_ref()
1002 .map(|a| (a.duration * a.sample_rate as f64) as usize)
1003 .unwrap_or(0);
1004
1005 self.edit.hash = Some(hash.to_string());
1006 self.edit.show_window = true;
1007
1008 // Reset all params to defaults
1009 self.edit.trim_start = 0.0;
1010 self.edit.trim_end = 1.0;
1011 self.edit.total_frames = total_frames;
1012 self.edit.gain_db = 0.0;
1013 self.edit.norm_peak = true;
1014 self.edit.norm_target = -0.1;
1015 self.edit.fade_in = true;
1016 self.edit.fade_duration_ms = 100.0;
1017 self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear;
1018
1019 // Cache result mode from user_config
1020 self.edit.result_mode = match self.backend.get_config("edit_result_mode").ok().flatten().as_deref() {
1021 Some("replace") => Some(super::EditResultMode::Replace),
1022 Some("sibling") => Some(super::EditResultMode::Sibling),
1023 _ => None,
1024 };
1025 }
1026
1027 /// Close the floating sample editor.
1028 pub fn close_edit_window(&mut self) {
1029 self.edit.show_window = false;
1030 self.edit.hash = None;
1031 }
1032
1033 /// M-11: best-effort cancel of the in-flight edit. Signals the worker via
1034 /// the backend and clears `in_progress` so the UI is interactive again.
1035 /// The worker may still finish writing its output; if it does, the result
1036 /// path will eventually be handled by `handle_edit_complete` as normal.
1037 pub fn cancel_edit_operation(&mut self) {
1038 let _ = self.backend.cancel_edit();
1039 self.edit.in_progress = false;
1040 self.status = "Edit cancelled.".to_string();
1041 }
1042
1043 /// M-12: discard the pending edit result without applying it. The result
1044 /// file held in `pending_result` is dropped; if the temp path still exists
1045 /// on disk it is removed.
1046 pub fn discard_edit_result(&mut self) {
1047 if let Some(pending) = self.edit.pending_result.take() {
1048 let _ = std::fs::remove_file(&pending.result_path);
1049 }
1050 self.edit.result_prompt = false;
1051 self.status = "Edit result discarded.".to_string();
1052 }
1053
1054 /// Apply trim to the current edit target.
1055 pub fn apply_edit_trim(&mut self) {
1056 let hash = match &self.edit.hash {
1057 Some(h) => h.clone(),
1058 None => return,
1059 };
1060 let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize;
1061 let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize;
1062 let op = audiofiles_core::edit::EditOperation::Trim { start_frame: start, end_frame: end };
1063 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1064 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1065 self.status = format!("Edit failed: {e}");
1066 }
1067 }
1068
1069 /// Apply gain adjustment to the current edit target.
1070 pub fn apply_edit_gain(&mut self) {
1071 let hash = match &self.edit.hash {
1072 Some(h) => h.clone(),
1073 None => return,
1074 };
1075 let op = audiofiles_core::edit::EditOperation::Gain { db: self.edit.gain_db };
1076 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1077 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1078 self.status = format!("Edit failed: {e}");
1079 }
1080 }
1081
1082 /// Apply normalize to the current edit target.
1083 pub fn apply_edit_normalize(&mut self) {
1084 let hash = match &self.edit.hash {
1085 Some(h) => h.clone(),
1086 None => return,
1087 };
1088 let op = if self.edit.norm_peak {
1089 audiofiles_core::edit::EditOperation::NormalizePeak { target_db: self.edit.norm_target }
1090 } else {
1091 audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs: self.edit.norm_target }
1092 };
1093 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1094 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1095 self.status = format!("Edit failed: {e}");
1096 }
1097 }
1098
1099 /// Apply reverse to the current edit target.
1100 pub fn apply_edit_reverse(&mut self) {
1101 let hash = match &self.edit.hash {
1102 Some(h) => h.clone(),
1103 None => return,
1104 };
1105 let op = audiofiles_core::edit::EditOperation::Reverse;
1106 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1107 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1108 self.status = format!("Edit failed: {e}");
1109 }
1110 }
1111
1112 /// Apply fade to the current edit target.
1113 pub fn apply_edit_fade(&mut self) {
1114 let hash = match &self.edit.hash {
1115 Some(h) => h.clone(),
1116 None => return,
1117 };
1118 // Convert ms to frames using sample rate from analysis
1119 let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
1120 .map(|a| a.sample_rate)
1121 .unwrap_or(44100);
1122 let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize;
1123 let op = if self.edit.fade_in {
1124 audiofiles_core::edit::EditOperation::FadeIn { frames, curve: self.edit.fade_curve }
1125 } else {
1126 audiofiles_core::edit::EditOperation::FadeOut { frames, curve: self.edit.fade_curve }
1127 };
1128 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1129 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1130 self.status = format!("Edit failed: {e}");
1131 }
1132 }
1133
1134 /// Insert silence at the configured position and duration.
1135 pub fn apply_edit_insert_silence(&mut self) {
1136 let hash = match &self.edit.hash {
1137 Some(h) => h.clone(),
1138 None => return,
1139 };
1140 let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
1141 .map(|a| a.sample_rate)
1142 .unwrap_or(44100);
1143 let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize;
1144 let duration_frames = ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize;
1145 let op = audiofiles_core::edit::EditOperation::InsertSilence { start_frame, duration_frames };
1146 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1147 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1148 self.status = format!("Edit failed: {e}");
1149 }
1150 }
1151
1152 /// Remove a range of frames between the configured start and end positions.
1153 pub fn apply_edit_remove_range(&mut self) {
1154 let hash = match &self.edit.hash {
1155 Some(h) => h.clone(),
1156 None => return,
1157 };
1158 let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
1159 .map(|a| a.sample_rate)
1160 .unwrap_or(44100);
1161 let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize;
1162 let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize;
1163 if start_frame >= end_frame {
1164 self.status = "Remove range: start must be before end".to_string();
1165 return;
1166 }
1167 let op = audiofiles_core::edit::EditOperation::RemoveRange { start_frame, end_frame };
1168 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
1169 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
1170 self.status = format!("Edit failed: {e}");
1171 }
1172 }
1173
1174 /// Apply an edit operation to all selected samples (batch edit).
1175 /// Skips directories and samples without hashes.
1176 pub fn batch_edit(&mut self, op_factory: impl Fn(&str) -> Option<audiofiles_core::edit::EditOperation>) {
1177 let hashes = self.selected_sample_hashes();
1178 if hashes.is_empty() {
1179 self.status = "No samples selected for batch edit".to_string();
1180 return;
1181 }
1182 let mut applied = 0;
1183 let mut errors = 0;
1184 for hash in &hashes {
1185 let Some(op) = op_factory(hash) else { continue; };
1186 let ext = self.backend.sample_extension(hash).unwrap_or_default();
1187 match self.backend.start_edit(hash, &ext, op) {
1188 Ok(()) => applied += 1,
1189 Err(_) => errors += 1,
1190 }
1191 }
1192 self.status = if errors > 0 {
1193 format!("Batch edit: {applied} applied, {errors} failed")
1194 } else {
1195 format!("Batch edit: {applied} samples processed")
1196 };
1197 }
1198
1199 /// Batch normalize (peak) all selected samples.
1200 pub fn batch_normalize_peak(&mut self, target_db: f64) {
1201 self.batch_edit(|_hash| {
1202 Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db })
1203 });
1204 }
1205
1206 /// Batch normalize (LUFS) all selected samples.
1207 pub fn batch_normalize_lufs(&mut self, target_lufs: f64) {
1208 self.batch_edit(|_hash| {
1209 Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs })
1210 });
1211 }
1212
1213 /// Batch reverse all selected samples.
1214 pub fn batch_reverse(&mut self) {
1215 self.batch_edit(|_hash| {
1216 Some(audiofiles_core::edit::EditOperation::Reverse)
1217 });
1218 }
1219
1220 /// Batch apply gain to all selected samples.
1221 pub fn batch_gain(&mut self, db: f64) {
1222 self.batch_edit(|_hash| {
1223 Some(audiofiles_core::edit::EditOperation::Gain { db })
1224 });
1225 }
1226
1227 /// Save the user's preferred edit result mode.
1228 pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) {
1229 let mode_str = match mode {
1230 super::EditResultMode::Replace => "replace",
1231 super::EditResultMode::Sibling => "sibling",
1232 };
1233 let _ = self.backend.set_config("edit_result_mode", mode_str);
1234 self.edit.result_mode = Some(mode);
1235 }
1236
1237 /// Handle a completed edit: import result, update VFS, record history.
1238 fn handle_edit_complete(
1239 &mut self,
1240 source_hash: String,
1241 result_path: PathBuf,
1242 operation: audiofiles_core::edit::EditOperation,
1243 ) {
1244 let op_name = operation.display_name().to_string();
1245
1246 // Use cached result mode preference
1247 let mode = match self.edit.result_mode {
1248 Some(m) => m,
1249 None => {
1250 // No preference set — store result and show prompt
1251 self.edit.pending_result = Some(super::PendingEditResult {
1252 source_hash,
1253 result_path,
1254 operation,
1255 });
1256 self.edit.result_prompt = true;
1257 return;
1258 }
1259 };
1260
1261 self.finalize_edit(source_hash, result_path, operation, mode);
1262 self.status = format!("Edit applied — {op_name}");
1263 }
1264
1265 /// Apply the chosen edit result mode to a pending edit.
1266 pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) {
1267 if remember {
1268 self.set_edit_result_mode(mode);
1269 }
1270
1271 if let Some(pending) = self.edit.pending_result.take() {
1272 let op_name = pending.operation.display_name().to_string();
1273 self.finalize_edit(pending.source_hash, pending.result_path, pending.operation, mode);
1274 self.status = format!("Edit applied — {op_name}");
1275 }
1276 self.edit.result_prompt = false;
1277 }
1278
1279 /// Common finalization: import result file, update VFS, record history, trigger analysis.
1280 fn finalize_edit(
1281 &mut self,
1282 source_hash: String,
1283 result_path: PathBuf,
1284 operation: audiofiles_core::edit::EditOperation,
1285 mode: super::EditResultMode,
1286 ) {
1287 // 1. Import the result file into the content-addressed store
1288 let new_hash = match self.backend.import_file(&result_path) {
1289 Ok(h) => h,
1290 Err(e) => {
1291 self.status = format!("Failed to import edit result: {e}");
1292 return;
1293 }
1294 };
1295
1296 // Clean up temp file
1297 let _ = std::fs::remove_file(&result_path);
1298
1299 // 2. Record in edit_history
1300 let _ = self.backend.record_edit_history(&source_hash, &new_hash, &operation);
1301
1302 // 3. Update VFS based on mode
1303 let Some(vfs_id) = self.current_vfs_id() else {
1304 self.status = "No VFS available".to_string();
1305 return;
1306 };
1307 let source_hashes = [source_hash.as_str()];
1308 let source_nodes = self.backend.find_nodes_by_hashes(vfs_id, &source_hashes)
1309 .unwrap_or_default();
1310
1311 let new_ext = self.backend.sample_extension(&new_hash).unwrap_or_else(|_| "wav".to_string());
1312
1313 // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit`
1314 // can walk them back. Captured before deletion in Replace mode and
1315 // after creation in Sibling mode.
1316 let mut replace_targets: Vec<(Option<audiofiles_core::NodeId>, String)> = Vec::new();
1317 let mut sibling_node_id: Option<audiofiles_core::NodeId> = None;
1318
1319 match mode {
1320 super::EditResultMode::Replace => {
1321 // Update each VFS node that references the source hash
1322 for node in &source_nodes {
1323 // Delete old node and create new one with same name/parent
1324 let parent_id = node.node.parent_id;
1325 let name = node.node.name.clone();
1326 replace_targets.push((parent_id, name.clone()));
1327 let _ = self.backend.delete_node(node.node.id);
1328 let _ = self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash);
1329 }
1330 }
1331 super::EditResultMode::Sibling => {
1332 // Create a sibling node next to the original
1333 if let Some(node) = source_nodes.first() {
1334 let parent_id = node.node.parent_id;
1335 let (stem, _ext) = split_name_ext(&node.node.name);
1336 let sibling_name = format!("{stem}_edited.{new_ext}");
1337 if let Ok(id) = self
1338 .backend
1339 .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash)
1340 {
1341 sibling_node_id = Some(id);
1342 }
1343 }
1344 }
1345 }
1346
1347 // C-1 part 2: stash the entry only if there's actually something to
1348 // undo (no VFS nodes → nothing happened that needs reversing).
1349 if !replace_targets.is_empty() || sibling_node_id.is_some() {
1350 self.edit.last_undo = Some(super::EditUndoEntry {
1351 op_name: operation.display_name().to_string(),
1352 source_hash: source_hash.clone(),
1353 result_hash: new_hash.clone(),
1354 mode,
1355 vfs_id,
1356 replace_targets,
1357 sibling_node_id,
1358 created_at: std::time::Instant::now(),
1359 });
1360 }
1361
1362 // 4. Trigger analysis on the new sample
1363 let hashes = vec![(new_hash, new_ext)];
1364 let _ = self.backend.start_analysis(hashes, AnalysisConfig::default());
1365
1366 // 5. Refresh the file list
1367 self.refresh_contents();
1368 }
1369
1370 /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks
1371 /// the VFS nodes back to the source hash; Sibling mode deletes the
1372 /// created sibling. The original sample blob is preserved by the
1373 /// content-addressed store so no audio data needs to be restored.
1374 ///
1375 /// Returns silently with no-op if `last_undo` is empty.
1376 pub fn undo_last_edit(&mut self) {
1377 let Some(entry) = self.edit.last_undo.take() else {
1378 return;
1379 };
1380
1381 match entry.mode {
1382 super::EditResultMode::Replace => {
1383 // Re-find any nodes now pointing at result_hash and clear them
1384 // first — the edit may have produced multiple nodes when the
1385 // sample appeared in multiple places, and we recreate from the
1386 // captured (parent_id, name) list.
1387 let result_hashes = [entry.result_hash.as_str()];
1388 if let Ok(result_nodes) = self
1389 .backend
1390 .find_nodes_by_hashes(entry.vfs_id, &result_hashes)
1391 {
1392 for node in &result_nodes {
1393 let _ = self.backend.delete_node(node.node.id);
1394 }
1395 }
1396 for (parent_id, name) in &entry.replace_targets {
1397 let _ = self.backend.create_sample_link(
1398 entry.vfs_id,
1399 *parent_id,
1400 name,
1401 &entry.source_hash,
1402 );
1403 }
1404 }
1405 super::EditResultMode::Sibling => {
1406 if let Some(id) = entry.sibling_node_id {
1407 let _ = self.backend.delete_node(id);
1408 }
1409 }
1410 }
1411
1412 // Drop the matching edit_history row so the audit trail reflects the
1413 // undo. Best-effort — the UI undo affordance already disappeared.
1414 let _ = self
1415 .backend
1416 .delete_edit_history(&entry.source_hash, &entry.result_hash);
1417
1418 self.status = format!("Reverted {}", entry.op_name);
1419 self.refresh_contents();
1420 }
1421 }
1422