//! Context menus and drag-out handlers extracted from file_list.rs. use egui; use crate::state::BrowserState; use audiofiles_core::vfs::NodeType; use super::theme; use super::widgets; #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] use crate::drag_out; /// Draw the right-click context menu for a single item. /// Branches on node type: samples get Preview/Copy Path/Delete, /// directories get Open/Delete. pub fn draw_context_menu( ui: &mut egui::Ui, state: &mut BrowserState, row_idx: usize, node: &audiofiles_core::vfs::VfsNodeWithAnalysis, sync_manager: Option<&audiofiles_sync::SyncManager>, ) { match node.node.node_type { NodeType::Sample => { if node.cloud_only { ui.label( egui::RichText::new("Cloud-only sample") .color(theme::text_muted()) .italics(), ); // Targeted download for the row under the cursor. Falls back // gracefully when sync isn't configured (CLAP plugin, dev // builds without an embedded API key) by hiding the item. if let Some(sync) = sync_manager && let Some(hash) = &node.node.sample_hash && ui .button("Download") .on_hover_text("Fetch this sample from the cloud to local storage") .clicked() { let hash = hash.to_string(); if sync.download_sample(&hash) { state.status = format!( "Downloading {}...", node.node.name ); } else { state.status = "Sync not ready — open the Sync panel first".to_string(); } ui.close(); } ui.separator(); } if !node.cloud_only && ui.button("Preview").clicked() { if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.trigger_preview(&hash); } ui.close(); } if ui.button("Copy Path").clicked() { if let Some(path) = state.selected_sample_path() { state.status = format!("Copied: {path}"); ui.ctx().copy_text(path); } ui.close(); } // M-6: one-click jump to the file in the system file manager. // macOS / Windows highlight the file itself; Linux falls back to // opening the parent directory (no widely-supported select flag). #[cfg(target_os = "macos")] let reveal_label = "Reveal in Finder"; #[cfg(target_os = "windows")] let reveal_label = "Show in Explorer"; #[cfg(target_os = "linux")] let reveal_label = "Open Containing Folder"; if !node.cloud_only && ui.button(reveal_label).clicked() { if let Some(path) = state.selected_sample_path() { #[cfg(target_os = "macos")] let _ = std::process::Command::new("open").args(["-R", &path]).spawn(); #[cfg(target_os = "windows")] let _ = std::process::Command::new("explorer") .arg(format!("/select,{}", path)) .spawn(); #[cfg(target_os = "linux")] { let parent = std::path::Path::new(&path) .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::path::PathBuf::from(&path)); let _ = std::process::Command::new("xdg-open").arg(&parent).spawn(); } } ui.close(); } if ui.button("Find Similar (Shift+F)").clicked() { if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.find_similar(&hash); } ui.close(); } if ui.button("Find Duplicates (Shift+D)").clicked() { if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); state.find_near_duplicates(&hash); } ui.close(); } // Add to Collection submenu if let Some(hash) = &node.node.sample_hash { let hash_clone = hash.clone(); let collections = state.collections.clone(); let is_in_collection = state.active_collection.is_some(); if !collections.is_empty() { ui.menu_button("Add to Collection", |ui| { for coll in &collections { if ui.button(&coll.name).clicked() { let _ = state.backend.add_to_collection(coll.id, &hash_clone); state.refresh_collections(); state.status = format!("Added to {}", coll.name); ui.close(); } } }); } if is_in_collection && let Some(active_id) = state.active_collection && widgets::danger_button(ui, "Remove from Collection").clicked() { let _ = state.backend.remove_from_collection(active_id, &hash_clone); state.refresh_collections(); state.activate_collection(active_id); ui.close(); } } if !node.cloud_only { if let Some(hash) = &node.node.sample_hash { let hash_clone = hash.clone(); if ui.button("Edit... (E)").clicked() { state.open_edit_window(&hash_clone); ui.close(); } } if ui.button("Play as Instrument").clicked() { if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); let name = node.node.name.clone(); state.load_chromatic_sample(&hash); state.instrument_visible = true; state.show_midi_window = true; state.status = format!("Instrument: {name}"); } ui.close(); } if ui.button("Export...").clicked() { state.selection.set_single(row_idx); state.start_export_flow(Some(vec![node.node.id])); ui.close(); } // M-7: single-row Re-analyze parity with the multi-row menu. // Reuses ReanalyzeOverwrite with a one-element vec so the // backend path matches the bulk case exactly. if ui .button("Re-analyze...") .on_hover_text("Run analysis again on this sample") .clicked() { if let Some(hash) = &node.node.sample_hash && let Ok(ext) = state.backend.sample_extension(hash) { let hashes = vec![(hash.to_string(), ext)]; let has_existing = node.bpm.is_some() || node.musical_key.is_some() || node.classification.is_some(); if has_existing { state.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite { sample_hashes: hashes, overwrite_count: 1, }); } else { state.start_analysis_flow(hashes); } } ui.close(); } } ui.separator(); if widgets::danger_button(ui, "Delete").clicked() { state.selection.set_single(row_idx); state.confirm_delete_selected(); ui.close(); } } NodeType::Directory => { if ui.button("Open").clicked() { state.selection.set_single(row_idx); state.enter_directory(); ui.close(); } if ui.button("New Folder").clicked() { state.show_dir_create = true; state.dir_create_input.clear(); ui.close(); } if ui.button("Rename").clicked() { state.dir_rename_target = Some((node.node.id, node.node.name.clone())); ui.close(); } if ui.button("Export...").clicked() { state.selection.set_single(row_idx); state.start_export_flow(Some(vec![node.node.id])); ui.close(); } ui.separator(); if widgets::danger_button(ui, "Delete").clicked() { state.selection.set_single(row_idx); state.confirm_delete_selected(); ui.close(); } } } } /// Context menu when multiple items are selected. pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { let count = state.selection.count(); ui.label(egui::RichText::new(format!("{count} items selected")).strong()); ui.separator(); if ui.button("Invert Selection (Cmd+Shift+I)").clicked() { state.invert_selection(); ui.close(); } ui.separator(); if ui.button("Tag... (Cmd+T)").clicked() { state.open_bulk_tag_modal(); ui.close(); } // m-16: Cmd+M conflicts with the macOS minimize-window shortcut. Label // advertises Cmd+Shift+M; the actual key binding lives in // `editor.rs` (search for "Cmd+M: bulk move") and must be updated // there to match. if ui.button("Move to... (Cmd+Shift+M)").clicked() { state.open_bulk_move_modal(); ui.close(); } if ui.button("Rename... (F2)").clicked() { state.open_bulk_rename_modal(); ui.close(); } if ui.button("Export...").clicked() { let node_ids = state.selected_node_ids(); state.start_export_flow(Some(node_ids)); ui.close(); } // Add to Collection submenu (bulk) let collections = state.collections.clone(); if !collections.is_empty() { ui.menu_button("Add to Collection", |ui| { for coll in &collections { if ui.button(&coll.name).clicked() { let nodes = state.selected_nodes(); for n in &nodes { if let Some(hash) = &n.node.sample_hash { let _ = state.backend.add_to_collection(coll.id, hash); } } state.refresh_collections(); state.status = format!("Added {} items to {}", nodes.len(), coll.name); ui.close(); } } }); } // Remove from Collection (when viewing a collection) if let Some(active_id) = state.active_collection && widgets::danger_button(ui, "Remove from Collection").clicked() { let nodes = state.selected_nodes(); for n in &nodes { if let Some(hash) = &n.node.sample_hash { let _ = state.backend.remove_from_collection(active_id, hash); } } state.refresh_collections(); state.activate_collection(active_id); ui.close(); } ui.separator(); if ui.button("Re-analyze...").on_hover_text("Run analysis again on selected samples").clicked() { let selected = state.selected_nodes(); let hashes: Vec<(String, String)> = selected .iter() .filter_map(|n| { let hash = n.node.sample_hash.as_ref()?; let ext = state.backend.sample_extension(hash).ok()?; Some((hash.to_string(), ext)) }) .collect(); // Count how many of the selected samples already have computed values // — re-analyzing those will overwrite the previous result, which a user // who hand-tuned the analysis would lose silently otherwise. let overwrite_count = selected .iter() .filter(|n| n.bpm.is_some() || n.musical_key.is_some() || n.classification.is_some()) .count(); if overwrite_count > 0 { state.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite { sample_hashes: hashes, overwrite_count, }); } else { state.start_analysis_flow(hashes); } ui.close(); } // Copy tags from focused sample to all selected if let Some(focused) = state.selected_node() && let Some(ref src_hash) = focused.node.sample_hash { let src_hash = src_hash.clone(); let src_name = focused.node.name.clone(); if ui.button(format!("Copy Tags from \"{}\"", truncate_name(&src_name, 20))) .on_hover_text("Apply this sample's tags to all other selected samples") .clicked() { let src_hash_str = src_hash.to_string(); if let Ok(src_tags) = state.backend.get_sample_tags(&src_hash) { let target_hashes = state.selected_sample_hashes(); let mut applied = 0; for hash in &target_hashes { if *hash == src_hash_str { continue; } for tag in &src_tags { let _ = state.backend.add_tag(hash, tag); } applied += 1; } state.status = format!("Copied {} tags to {} samples", src_tags.len(), applied); state.refresh_selected_tags(); } ui.close(); } } ui.separator(); if ui.button("Copy Path").clicked() { let nodes = state.selected_nodes(); let paths: Vec = nodes .iter() .filter_map(|n| { n.node.sample_hash.as_ref().and_then(|hash| { let ext = state.backend.sample_extension(hash).ok()?; Some(state.backend.sample_path(hash, &ext).ok()?.to_string_lossy().into_owned()) }) }) .collect(); if !paths.is_empty() { // Include the first path in the status so the user can recognise the // clipboard contents at a glance — the bare count "Copied N paths" // gave no way to verify which selection won the race when the user // copied, then changed selection, then pasted into a DAW. let first = &paths[0]; let count = paths.len(); state.status = if count == 1 { format!("Copied: {first}") } else { format!("Copied: {first} (+{} more)", count - 1) }; ui.ctx().copy_text(paths.join("\n")); } ui.close(); } if widgets::danger_button(ui, "Delete").clicked() { state.confirm_delete_selected(); ui.close(); } } /// Context menu for right-clicking empty space in the file list. pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { if ui.button("New Folder").clicked() { state.show_dir_create = true; state.dir_create_input.clear(); ui.close(); } if ui.button("Import files...").clicked() { if let Some(paths) = rfd::FileDialog::new() .set_title("Import files") .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS) .pick_files() { for path in paths { state.import_path(&path); } } ui.close(); } // C-2: matches the toolbar's "Import folder..." (wizard path). The quick // import shortcut is only offered from the toolbar to keep this menu // short; users who want quick-import find it there. if ui.button("Import folder...").clicked() { if let Some(path) = rfd::FileDialog::new().pick_folder() { state.show_import_options(path); } ui.close(); } if state.selection.count() > 0 { ui.separator(); let label = format!("Deselect ({}) (Esc)", state.selection.count()); if ui.button(label).clicked() { state.selection.clear(); state.refresh_selected_tags(); state.refresh_selected_detail(); ui.close(); } if ui.button("Invert Selection (Cmd+Shift+I)").clicked() { state.invert_selection(); ui.close(); } } } /// Truncate a name for display in menus (avoids excessively wide menu items). fn truncate_name(name: &str, max_len: usize) -> String { if name.chars().count() <= max_len { name.to_string() } else { let truncated: String = name.chars().take(max_len.saturating_sub(3)).collect(); format!("{truncated}...") } } #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] pub fn start_os_drag(state: &mut BrowserState) { let nodes = state.selected_nodes(); let files: Vec = nodes .iter() .filter(|n| n.node.node_type == NodeType::Sample && !n.cloud_only) .filter_map(|n| { let hash = n.node.sample_hash.as_ref()?; let ext = state.backend.sample_extension(hash).ok()?; let store_path = state.backend.sample_path(hash, &ext).ok()?; Some(drag_out::DragFile { friendly_name: n.node.name.clone(), store_path, }) }) .collect(); if !files.is_empty() { let count = files.len(); let first = files[0].friendly_name.clone(); if drag_out::begin_drag(&files) { state.os_drag_cooldown = Some(std::time::Instant::now()); state.status = if count == 1 { format!("Dragged {first}") } else { format!("Dragged {count} samples") }; } } }