//! Sample edit workflow: the floating editor window, single and batch edit //! operations, result-mode handling, and undo. Edit-completion events from //! the worker land in `poll_workers`, which calls `handle_edit_complete`. use super::{AnalysisConfig, BrowserState, PathBuf, split_name_ext}; impl BrowserState { /// Open the floating sample editor for the given hash. pub fn open_edit_window(&mut self, hash: &str) { // Load analysis for total frames and result mode preference let analysis = self.backend.get_analysis(hash).ok().flatten(); let total_frames = analysis .as_ref() .map_or(0, |a| (a.duration * a.sample_rate as f64) as usize); self.edit.hash = Some(hash.to_string()); self.edit.show_window = true; // Reset all params to defaults self.edit.trim_start = 0.0; self.edit.trim_end = 1.0; self.edit.total_frames = total_frames; self.edit.gain_db = 0.0; self.edit.norm_peak = true; self.edit.norm_target = -0.1; self.edit.fade_in = true; self.edit.fade_duration_ms = 100.0; self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear; // Cache result mode from user_config self.edit.result_mode = match self .backend .get_config(crate::backend::ConfigKey::EditResultMode) .ok() .flatten() .as_deref() { Some("replace") => Some(super::EditResultMode::Replace), Some("sibling") => Some(super::EditResultMode::Sibling), _ => None, }; } /// Close the floating sample editor. pub fn close_edit_window(&mut self) { self.edit.show_window = false; self.edit.hash = None; } /// M-11: best-effort cancel of the in-flight edit. Signals the worker via /// the backend and clears `in_progress` so the UI is interactive again. /// The worker may still finish writing its output; if it does, the result /// path will eventually be handled by `handle_edit_complete` as normal. pub fn cancel_edit_operation(&mut self) { let _ = self.backend.cancel_edit(); self.edit.in_progress = false; self.status = "Edit cancelled.".to_string(); } /// M-12: discard the pending edit result without applying it. The result /// file held in `pending_result` is dropped; if the temp path still exists /// on disk it is removed. pub fn discard_edit_result(&mut self) { if let Some(pending) = self.edit.pending_result.take() { let _ = std::fs::remove_file(&pending.result_path); } self.edit.result_prompt = false; self.status = "Edit result discarded.".to_string(); } /// Apply trim to the current edit target. pub fn apply_edit_trim(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; // total_frames falls back to 0 when analysis is absent; a 0-length span // (or an inverted one) would dispatch Trim{0,0} and produce a zero-length // sample. Guard it like apply_edit_remove_range does (fuzz-2026-07-06 B4). if self.edit.total_frames == 0 { self.status = "Cannot trim: sample length is unknown.".to_string(); return; } let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize; let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize; if start >= end { self.status = "Trim range: start must be before end".to_string(); return; } let op = audiofiles_core::edit::EditOperation::Trim { start_frame: start, end_frame: end, }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply gain adjustment to the current edit target. pub fn apply_edit_gain(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let op = audiofiles_core::edit::EditOperation::Gain { db: self.edit.gain_db, }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply normalize to the current edit target. pub fn apply_edit_normalize(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let op = if self.edit.norm_peak { audiofiles_core::edit::EditOperation::NormalizePeak { target_db: self.edit.norm_target, } } else { audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs: self.edit.norm_target, } }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply reverse to the current edit target. pub fn apply_edit_reverse(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let op = audiofiles_core::edit::EditOperation::Reverse; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply fade to the current edit target. pub fn apply_edit_fade(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; // Convert ms to frames using sample rate from analysis let sample_rate = self .backend .get_analysis(&hash) .ok() .flatten() .map_or(44100, |a| a.sample_rate); let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize; let op = if self.edit.fade_in { audiofiles_core::edit::EditOperation::FadeIn { frames, curve: self.edit.fade_curve, } } else { audiofiles_core::edit::EditOperation::FadeOut { frames, curve: self.edit.fade_curve, } }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Insert silence at the configured position and duration. pub fn apply_edit_insert_silence(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let sample_rate = self .backend .get_analysis(&hash) .ok() .flatten() .map_or(44100, |a| a.sample_rate); let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize; let duration_frames = ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize; let op = audiofiles_core::edit::EditOperation::InsertSilence { start_frame, duration_frames, }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Remove a range of frames between the configured start and end positions. pub fn apply_edit_remove_range(&mut self) { let hash = match &self.edit.hash { Some(h) => h.clone(), None => return, }; let sample_rate = self .backend .get_analysis(&hash) .ok() .flatten() .map_or(44100, |a| a.sample_rate); let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize; let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize; if start_frame >= end_frame { self.status = "Remove range: start must be before end".to_string(); return; } let op = audiofiles_core::edit::EditOperation::RemoveRange { start_frame, end_frame, }; let ext = self.backend.sample_extension(&hash).unwrap_or_default(); if let Err(e) = self.backend.start_edit(&hash, &ext, op) { self.status = format!("Edit failed: {e}"); } } /// Apply an edit operation to all selected samples (batch edit). /// Skips directories and samples without hashes. pub fn batch_edit( &mut self, op_factory: impl Fn(&str) -> Option, ) { let hashes = self.selected_sample_hashes(); if hashes.is_empty() { self.status = "No samples selected for batch edit".to_string(); return; } let mut applied = 0; let mut errors = 0; for hash in &hashes { let Some(op) = op_factory(hash) else { continue; }; let ext = self.backend.sample_extension(hash).unwrap_or_default(); match self.backend.start_edit(hash, &ext, op) { Ok(()) => applied += 1, Err(_) => errors += 1, } } self.status = if errors > 0 { format!("Batch edit: {applied} applied, {errors} failed") } else { format!("Batch edit: {applied} samples processed") }; } /// Batch normalize (peak) all selected samples. pub fn batch_normalize_peak(&mut self, target_db: f64) { self.batch_edit(|_hash| { Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db }) }); } /// Batch normalize (LUFS) all selected samples. pub fn batch_normalize_lufs(&mut self, target_lufs: f64) { self.batch_edit(|_hash| { Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs }) }); } /// Batch reverse all selected samples. pub fn batch_reverse(&mut self) { self.batch_edit(|_hash| Some(audiofiles_core::edit::EditOperation::Reverse)); } /// Batch apply gain to all selected samples. pub fn batch_gain(&mut self, db: f64) { self.batch_edit(|_hash| Some(audiofiles_core::edit::EditOperation::Gain { db })); } /// Save the user's preferred edit result mode. pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) { let mode_str = match mode { super::EditResultMode::Replace => "replace", super::EditResultMode::Sibling => "sibling", }; super::log_backend_err( "set_config edit_result_mode", self.backend .set_config(crate::backend::ConfigKey::EditResultMode, mode_str), ); self.edit.result_mode = Some(mode); } /// Handle a completed edit: import result, update VFS, record history. pub(super) fn handle_edit_complete( &mut self, source_hash: String, result_path: PathBuf, operation: audiofiles_core::edit::EditOperation, ) { let op_name = operation.display_name().to_string(); // Use cached result mode preference let Some(mode) = self.edit.result_mode else { // No preference set, store result and show prompt self.edit.pending_result = Some(super::PendingEditResult { source_hash, result_path, operation, }); self.edit.result_prompt = true; return; }; self.finalize_edit(&source_hash, &result_path, &operation, mode); self.status = format!("Edit applied, {op_name}"); } /// Apply the chosen edit result mode to a pending edit. pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) { if remember { self.set_edit_result_mode(mode); } if let Some(pending) = self.edit.pending_result.take() { let op_name = pending.operation.display_name().to_string(); self.finalize_edit( &pending.source_hash, &pending.result_path, &pending.operation, mode, ); self.status = format!("Edit applied, {op_name}"); } self.edit.result_prompt = false; } /// Common finalization: import result file, update VFS, record history, trigger analysis. fn finalize_edit( &mut self, source_hash: &str, result_path: &std::path::Path, operation: &audiofiles_core::edit::EditOperation, mode: super::EditResultMode, ) { // 1. Import the result file into the content-addressed store. Always // clean up the temp regardless of outcome, the prior code only removed // on the success branch, leaking the temp on every import failure. // (The temp is the user's edit output; after import_file copies it // into the store, we own the canonical copy by hash.) let import_result = self.backend.import_file(result_path); let _ = std::fs::remove_file(result_path); let new_hash = match import_result { Ok(h) => h, Err(e) => { self.status = format!("Failed to import edit result: {e}"); return; } }; // 2. Record in edit_history super::log_backend_err( "record_edit_history", self.backend .record_edit_history(source_hash, &new_hash, operation), ); // 3. Update VFS based on mode let Some(vfs_id) = self.current_vfs_id() else { self.status = "No VFS available".to_string(); return; }; let source_hashes = [source_hash]; let source_nodes = self .backend .find_nodes_by_hashes(vfs_id, &source_hashes) .unwrap_or_default(); let new_ext = self .backend .sample_extension(&new_hash) .unwrap_or_else(|_| "wav".to_string()); // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit` // can walk them back. Captured before deletion in Replace mode and // after creation in Sibling mode. let mut replace_targets: Vec<(Option, String)> = Vec::new(); let mut sibling_node_id: Option = None; match mode { super::EditResultMode::Replace => { // Update each VFS node that references the source hash for node in &source_nodes { // Delete old node and create new one with same name/parent let parent_id = node.node.parent_id; let name = node.node.name.clone(); replace_targets.push((parent_id, name.clone())); super::log_backend_err( "delete_node (edit VFS update)", self.backend.delete_node(node.node.id), ); super::log_backend_err( "create_sample_link (edit replace)", self.backend .create_sample_link(vfs_id, parent_id, &name, &new_hash), ); } } super::EditResultMode::Sibling => { // Create a sibling node next to the original if let Some(node) = source_nodes.first() { let parent_id = node.node.parent_id; let (stem, _ext) = split_name_ext(&node.node.name); let sibling_name = format!("{stem}_edited.{new_ext}"); if let Ok(id) = self.backend .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash) { sibling_node_id = Some(id); } } } } // C-1 part 2: stash the entry only if there's actually something to // undo (no VFS nodes → nothing happened that needs reversing). if !replace_targets.is_empty() || sibling_node_id.is_some() { self.edit.last_undo = Some(super::EditUndoEntry { op_name: operation.display_name().to_string(), source_hash: source_hash.to_string(), result_hash: new_hash.clone(), mode, vfs_id, replace_targets, sibling_node_id, created_at: std::time::Instant::now(), }); } // 4. Trigger analysis on the new sample let hashes = vec![(new_hash, new_ext)]; super::log_backend_err( "start_analysis (post-edit)", self.backend .start_analysis(hashes, AnalysisConfig::default()), ); // 5. Refresh the file list self.refresh_contents(); } /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks /// the VFS nodes back to the source hash; Sibling mode deletes the /// created sibling. The original sample blob is preserved by the /// content-addressed store so no audio data needs to be restored. /// /// Returns silently with no-op if `last_undo` is empty. pub fn undo_last_edit(&mut self) { let Some(entry) = self.edit.last_undo.take() else { return; }; match entry.mode { super::EditResultMode::Replace => { // Re-find any nodes now pointing at result_hash and clear them // first, the edit may have produced multiple nodes when the // sample appeared in multiple places, and we recreate from the // captured (parent_id, name) list. let result_hashes = [entry.result_hash.as_str()]; if let Ok(result_nodes) = self .backend .find_nodes_by_hashes(entry.vfs_id, &result_hashes) { for node in &result_nodes { super::log_backend_err( "delete_node (edit VFS update)", self.backend.delete_node(node.node.id), ); } } for (parent_id, name) in &entry.replace_targets { super::log_backend_err( "create_sample_link (edit undo)", self.backend.create_sample_link( entry.vfs_id, *parent_id, name, &entry.source_hash, ), ); } } super::EditResultMode::Sibling => { if let Some(id) = entry.sibling_node_id { super::log_backend_err( "delete_node (edit undo sibling)", self.backend.delete_node(id), ); } } } // Drop the matching edit_history row so the audit trail reflects the // undo. Best-effort, the UI undo affordance already disappeared. let _ = self .backend .delete_edit_history(&entry.source_hash, &entry.result_hash); self.status = format!("Reverted {}", entry.op_name); self.refresh_contents(); } }