use tracing::warn; use super::*; impl BrowserState { /// Show the delete confirmation dialog for the current selection. pub fn confirm_delete_selected(&mut self) { if self.selection.count() > 1 { self.confirm_delete_selection(); } else if let Some(node) = self.selected_node() { self.pending_confirm = Some(ConfirmAction::DeleteNode { node_id: node.node.id, node_name: node.node.name.clone(), }); } } /// Execute the pending confirmed action (delete node, delete VFS, or bulk delete). pub fn execute_confirmed_action(&mut self) { // Handle bulk delete separately since it needs &mut self if matches!(&self.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })) { self.execute_bulk_delete(); return; } let action = self.pending_confirm.take(); match action { Some(ConfirmAction::DeleteNode { node_id, node_name }) => { match self.backend.delete_node(node_id) { Ok(()) => { self.refresh_contents(); self.status = format!("Deleted \"{node_name}\""); } Err(e) => self.status = format!("Delete failed: {e}"), } } Some(ConfirmAction::DeleteVfs { vfs_id, vfs_name }) => { match self.backend.delete_vfs(vfs_id) { Ok(()) => { // Start background cleanup for orphaned samples if self.backend.start_cleanup().is_ok() { self.import_mode = ImportMode::Cleaning { completed: 0, total: 0, current_name: String::new(), }; self.refresh_vfs_list(); } else { // Fallback: synchronous cleanup let orphans = self.backend.remove_orphaned_samples().unwrap_or(0); self.refresh_vfs_list(); // Note: deleting a VFS row (a sub-vault inside the // current library) is what `delete_vfs` does. The // user-facing word for that inner concept is still // "vault" — we only renamed the registry-level // concept to "library." if orphans > 0 { self.status = format!("Vault \"{vfs_name}\" deleted ({orphans} samples removed)"); } else { self.status = format!("Vault \"{vfs_name}\" deleted"); } } } Err(e) => self.status = format!("Delete failed: {e}"), } } Some(ConfirmAction::SwitchLibrary { path, .. }) => { // User confirmed despite in-flight work — set the action; // the app layer's SwitchVault handler will tear down the // current browser (cancelling in-flight workers in the process). self.settings.pending_action = Some(crate::state::VaultAction::SwitchVault(path)); } Some(ConfirmAction::RemoveTagGlobally { tag }) => { match self.backend.remove_tag_globally(&tag) { Ok(count) => { self.refresh_all_tags(); self.search_filter.required_tags.retain(|t| t != &tag); self.apply_search(); self.status = format!("Removed tag \"{tag}\" from {count} sample{}", if count == 1 { "" } else { "s" }); } Err(e) => self.status = format!("Remove tag failed: {e}"), } } Some(ConfirmAction::DeleteCollection { coll_id, coll_name }) => { match self.backend.delete_collection(coll_id) { Ok(()) => { // Deactivate if the deleted collection was the active one. if self.active_collection == Some(coll_id) { self.deactivate_collection(); } self.refresh_collections(); self.status = format!("Deleted collection: {coll_name}"); } Err(e) => self.status = format!("Delete failed: {e}"), } } Some(ConfirmAction::ReanalyzeOverwrite { sample_hashes, .. }) => { self.start_analysis_flow(sample_hashes); } Some(ConfirmAction::DisconnectSync { .. }) => { // The actual sync.disconnect() happens in sync_panel.rs next // frame — bulk_ops.rs runs without a SyncManager handle. self.sync.pending_disconnect = true; } Some(ConfirmAction::RemoveFailedSamples { single_index, .. }) => { match single_index { Some(idx) => self.remove_failed_sample(idx), None => self.remove_all_failed_samples(), } } Some(ConfirmAction::ReverseSamples { .. }) => { // m-16: confirmed large-batch Reverse. The unconfirmed path // for selections ≤10 calls batch_reverse() directly from the // edit panel; only the gated case routes through here. self.batch_reverse(); } Some(ConfirmAction::DeleteMultiple { .. }) => unreachable!(), None => {} } } /// Dismiss the confirmation dialog without taking action. pub fn dismiss_confirm(&mut self) { self.pending_confirm = None; } // --- Bulk operations --- /// All selected nodes, excluding ".." parent entry. pub fn selected_nodes(&self) -> Vec { let offset = if self.current_dir.is_some() { 1 } else { 0 }; self.selection .selected .iter() .filter_map(|&idx| { if offset > 0 && idx == 0 { return None; // ".." entry } self.contents.get(idx - offset).cloned() }) .collect() } /// Node IDs of all selected items, excluding ".." parent entry. pub fn selected_node_ids(&self) -> Vec { let offset = if self.current_dir.is_some() { 1 } else { 0 }; self.selection .selected .iter() .filter_map(|&idx| { if offset > 0 && idx == 0 { return None; } self.contents.get(idx - offset).map(|n| n.node.id) }) .collect() } /// Hashes of all selected samples. pub fn selected_sample_hashes(&self) -> Vec { let offset = if self.current_dir.is_some() { 1 } else { 0 }; self.selection .selected .iter() .filter_map(|&idx| { if offset > 0 && idx == 0 { return None; } self.contents .get(idx - offset) .and_then(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) }) .collect() } /// Push an undoable operation onto the stack (capped at 100 entries). pub(crate) fn push_undo(&mut self, op: UndoOp) { self.undo_stack.push(op); // Cap at 100 entries to bound memory usage. Each UndoOp can hold a full // subtree snapshot (nodes + tags), so unbounded growth is not acceptable. if self.undo_stack.len() > 100 { let excess = self.undo_stack.len() - 100; self.undo_stack.drain(..excess); } } /// Whether there is an undoable operation on the stack. pub fn can_undo(&self) -> bool { !self.undo_stack.is_empty() } /// Pop and reverse the most recent bulk operation (delete, move, rename, or tag change). pub fn undo(&mut self) { let op = match self.undo_stack.pop() { Some(op) => op, None => return, }; match op { UndoOp::BulkDelete { nodes, tags: tag_data } => { for node in &nodes { let _ = self.backend.restore_node(node); } for (hash, sample_tags) in &tag_data { for tag in sample_tags { let _ = self.backend.add_tag(hash, tag); } } self.status = "Undo: restored deleted items".to_string(); } UndoOp::BulkMove { moves } => { for (node_id, old_parent) in &moves { let _ = self.backend.move_node(*node_id, *old_parent); } self.status = "Undo: moved items back".to_string(); } UndoOp::BulkRename { renames } => { for (node_id, old_name) in &renames { let _ = self.backend.rename_node(*node_id, old_name); } self.status = "Undo: restored original names".to_string(); } UndoOp::BulkTagAdd { tag, hashes } => { let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); let _ = self.backend.bulk_remove_tag(&refs, &tag); self.status = "Undo: removed tag".to_string(); } UndoOp::BulkTagRemove { tag, hashes } => { let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); let _ = self.backend.bulk_add_tag(&refs, &tag); self.status = "Undo: re-added tag".to_string(); } UndoOp::TagRemove { hash, tag } => { let _ = self.backend.add_tag(&hash, &tag); self.status = format!("Undo: restored tag \"{tag}\""); } } self.refresh_contents(); self.refresh_all_tags(); self.refresh_selected_tags(); } /// Close the active bulk operation modal without applying changes. pub fn close_bulk_modal(&mut self) { self.bulk_modal = None; } /// Open the bulk tag add/remove modal for the current selection. pub fn open_bulk_tag_modal(&mut self) { let nodes = self.selected_nodes(); let hashes = self.selected_sample_hashes(); if hashes.is_empty() { return; } let names: Vec = nodes .iter() .filter(|n| n.node.sample_hash.is_some()) .map(|n| n.node.name.clone()) .collect(); self.bulk_modal = Some(BulkModal::Tag { tag_input: String::new(), adding: true, hashes, names, }); } /// Open the bulk move modal, listing all directories as potential targets. pub fn open_bulk_move_modal(&mut self) { let nodes = self.selected_nodes(); if nodes.is_empty() { return; } let node_ids: Vec = nodes.iter().map(|n| n.node.id).collect(); let names: Vec = nodes.iter().map(|n| n.node.name.clone()).collect(); let Some(vfs_id) = self.current_vfs_id() else { return }; let directories = self.backend.list_all_directories(vfs_id).unwrap_or_else(|e| { warn!("Failed to list directories: {e}"); Vec::new() }); self.bulk_modal = Some(BulkModal::Move { node_ids, names, directories, selected_idx: None, }); } /// Open the bulk rename modal with a pattern input and live previews. pub fn open_bulk_rename_modal(&mut self) { let nodes = self.selected_nodes(); if nodes.is_empty() { return; } let targets: Vec = nodes .iter() .enumerate() .map(|(i, n)| { let (stem, ext) = split_name_ext(&n.node.name); RenameTarget { node_id: n.node.id, context: audiofiles_core::rename::RenameContext { name: stem, extension: ext, bpm: n.bpm, musical_key: n.musical_key.clone(), classification: n.classification.clone(), duration: n.duration, index: i, }, } }) .collect(); self.bulk_modal = Some(BulkModal::Rename { pattern_input: "{name}".to_string(), targets, previews: Vec::new(), error: None, }); } /// Apply the bulk tag operation (add or remove) and push an undo entry. pub fn execute_bulk_tag(&mut self) { let (tag_input, adding, hashes) = match &self.bulk_modal { Some(BulkModal::Tag { tag_input, adding, hashes, .. }) => (tag_input.clone(), *adding, hashes.clone()), _ => return, }; let tag = tag_input.trim(); if tag.is_empty() { return; } let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); if adding { match self.backend.bulk_add_tag(&refs, tag) { Ok(count) => { self.push_undo(UndoOp::BulkTagAdd { tag: tag.to_string(), hashes, }); self.status = format!("Tagged {count} samples with \"{tag}\""); } Err(e) => { self.status = format!("Tag error: {e}"); } } } else { match self.backend.bulk_remove_tag(&refs, tag) { Ok(count) => { self.push_undo(UndoOp::BulkTagRemove { tag: tag.to_string(), hashes, }); self.status = format!("Removed tag \"{tag}\" from {count} samples"); } Err(e) => { self.status = format!("Tag error: {e}"); } } } self.bulk_modal = None; self.refresh_selected_tags(); self.refresh_all_tags(); } /// Apply a tag to an arbitrary list of sample hashes and push an undo entry. /// Used by the multi-summary's right-click "Apply to remaining" affordance /// (M-11), which targets the subset that doesn't yet carry the tag. pub fn apply_tag_to_hashes(&mut self, tag: &str, hashes: &[String]) { if hashes.is_empty() || tag.is_empty() { return; } let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); match self.backend.bulk_add_tag(&refs, tag) { Ok(count) => { self.push_undo(UndoOp::BulkTagAdd { tag: tag.to_string(), hashes: hashes.to_vec(), }); self.status = format!("Tagged {count} samples with \"{tag}\""); } Err(e) => { self.status = format!("Tag error: {e}"); } } self.refresh_selected_tags(); self.refresh_all_tags(); } /// Remove a tag from an arbitrary list of sample hashes and push an undo entry. /// Companion to `apply_tag_to_hashes` for the multi-summary badges (M-11). pub fn remove_tag_from_hashes(&mut self, tag: &str, hashes: &[String]) { if hashes.is_empty() || tag.is_empty() { return; } let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); match self.backend.bulk_remove_tag(&refs, tag) { Ok(count) => { self.push_undo(UndoOp::BulkTagRemove { tag: tag.to_string(), hashes: hashes.to_vec(), }); self.status = format!("Removed tag \"{tag}\" from {count} samples"); } Err(e) => { self.status = format!("Tag error: {e}"); } } self.refresh_selected_tags(); self.refresh_all_tags(); } /// Move all selected nodes to the chosen directory. Snapshots old parents for undo. pub fn execute_bulk_move(&mut self) { let (node_ids, selected_idx, directories) = match &self.bulk_modal { Some(BulkModal::Move { node_ids, selected_idx, directories, .. }) => (node_ids.clone(), *selected_idx, directories.clone()), _ => return, }; let target_parent = selected_idx.map(|idx| directories[idx].0); let mut moves = Vec::new(); for &node_id in &node_ids { if let Ok(node) = self.backend.get_node(node_id) { moves.push((node_id, node.parent_id)); let _ = self.backend.move_node(node_id, target_parent); } } self.push_undo(UndoOp::BulkMove { moves }); let dest_name = selected_idx .map(|idx| directories[idx].1.as_str()) .unwrap_or("root"); self.status = format!("Moved {} items to {}", node_ids.len(), dest_name); self.bulk_modal = None; self.refresh_contents(); } /// Apply the rename pattern to all selected nodes. Snapshots old names for undo. pub fn execute_bulk_rename(&mut self) { let (pattern_input, targets) = match &self.bulk_modal { Some(BulkModal::Rename { pattern_input, targets, .. }) => (pattern_input.clone(), targets), _ => return, }; let pattern = match audiofiles_core::rename::RenamePattern::parse(&pattern_input) { Ok(p) => p, Err(e) => { self.status = format!("Rename error: {e}"); return; } }; let new_stems = pattern.resolve_all( &targets .iter() .map(|t| audiofiles_core::rename::RenameContext { name: t.context.name.clone(), extension: t.context.extension.clone(), bpm: t.context.bpm, musical_key: t.context.musical_key.clone(), classification: t.context.classification.clone(), duration: t.context.duration, index: t.context.index, }) .collect::>(), ); let mut renames = Vec::new(); for (target, new_stem) in targets.iter().zip(new_stems.iter()) { if let Ok(node) = self.backend.get_node(target.node_id) { let new_name = if target.context.extension.is_empty() { new_stem.clone() } else { format!("{}.{}", new_stem, target.context.extension) }; renames.push((target.node_id, node.name.clone())); let _ = self.backend.rename_node(target.node_id, &new_name); } } self.push_undo(UndoOp::BulkRename { renames }); self.status = format!( "Renamed {} items (pattern: {})", new_stems.len(), pattern_input ); self.bulk_modal = None; self.refresh_contents(); } /// Delete all confirmed nodes, snapshotting the full subtree and tags for undo. pub fn execute_bulk_delete(&mut self) { let node_ids = match &self.pending_confirm { Some(ConfirmAction::DeleteMultiple { node_ids, .. }) => node_ids.clone(), _ => return, }; self.pending_confirm = None; // Snapshot for undo let mut all_nodes = Vec::new(); let mut all_tags = Vec::new(); for &node_id in &node_ids { if let Ok(subtree) = self.backend.collect_subtree(node_id) { for node in &subtree { if let Some(ref hash) = node.sample_hash { let sample_tags = self.backend.get_sample_tags(hash).unwrap_or_default(); if !sample_tags.is_empty() { all_tags.push((hash.to_string(), sample_tags)); } } } all_nodes.extend(subtree); } } // Delete for &node_id in &node_ids { let _ = self.backend.delete_node(node_id); } self.push_undo(UndoOp::BulkDelete { nodes: all_nodes, tags: all_tags, }); self.status = format!("Deleted {} items", node_ids.len()); self.refresh_contents(); } /// Build a delete confirmation for the current multi-selection. pub fn confirm_delete_selection(&mut self) { let nodes = self.selected_nodes(); if nodes.is_empty() { return; } if nodes.len() == 1 { self.pending_confirm = Some(ConfirmAction::DeleteNode { node_id: nodes[0].node.id, node_name: nodes[0].node.name.clone(), }); } else { let node_ids: Vec = nodes.iter().map(|n| n.node.id).collect(); let count = node_ids.len(); self.pending_confirm = Some(ConfirmAction::DeleteMultiple { node_ids, count }); } } /// Update rename previews when pattern input changes. pub fn update_rename_previews(&mut self) { if let Some(BulkModal::Rename { ref pattern_input, ref targets, ref mut previews, ref mut error, }) = self.bulk_modal { match audiofiles_core::rename::RenamePattern::parse(pattern_input) { Ok(pattern) => { let contexts: Vec = targets .iter() .map(|t| audiofiles_core::rename::RenameContext { name: t.context.name.clone(), extension: t.context.extension.clone(), bpm: t.context.bpm, musical_key: t.context.musical_key.clone(), classification: t.context.classification.clone(), duration: t.context.duration, index: t.context.index, }) .collect(); let new_stems = pattern.resolve_all(&contexts); *previews = targets .iter() .zip(new_stems.iter()) .map(|(t, new)| { let old = if t.context.extension.is_empty() { t.context.name.clone() } else { format!("{}.{}", t.context.name, t.context.extension) }; let new_full = if t.context.extension.is_empty() { new.clone() } else { format!("{}.{}", new, t.context.extension) }; (old, new_full) }) .collect(); // Validate: no empty names if previews.iter().any(|(_, new)| new.trim().is_empty()) { *error = Some("Rename would produce empty file names".to_string()); return; } // Validate: no duplicates let mut seen = std::collections::HashSet::new(); let has_dupes = previews.iter().any(|(_, new)| !seen.insert(new.to_lowercase())); if has_dupes { *error = Some("Rename would produce duplicate file names".to_string()); return; } *error = None; } Err(e) => { previews.clear(); *error = Some(e.to_string()); } } } } }