Skip to main content

max / audiofiles

19.1 KB · 478 lines History Blame Raw
1 //! Context menus and drag-out handlers extracted from file_list.rs.
2
3 use egui;
4
5 use crate::state::BrowserState;
6 use audiofiles_core::vfs::NodeType;
7
8 use super::theme;
9 use super::widgets;
10
11 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
12 use crate::drag_out;
13
14 /// Draw the right-click context menu for a single item.
15 /// Branches on node type: samples get Preview/Copy Path/Delete,
16 /// directories get Open/Delete.
17 pub fn draw_context_menu(
18 ui: &mut egui::Ui,
19 state: &mut BrowserState,
20 row_idx: usize,
21 node: &audiofiles_core::vfs::VfsNodeWithAnalysis,
22 sync_manager: Option<&audiofiles_sync::SyncManager>,
23 ) {
24 match node.node.node_type {
25 NodeType::Sample => {
26 if node.cloud_only {
27 ui.label(
28 egui::RichText::new("Cloud-only sample")
29 .color(theme::text_muted())
30 .italics(),
31 );
32 // Targeted download for the row under the cursor. Falls back
33 // gracefully when sync isn't configured (CLAP plugin, dev
34 // builds without an embedded API key) by hiding the item.
35 if let Some(sync) = sync_manager {
36 if let Some(hash) = &node.node.sample_hash {
37 if ui
38 .button("Download")
39 .on_hover_text("Fetch this sample from the cloud to local storage")
40 .clicked()
41 {
42 let hash = hash.to_string();
43 if sync.download_sample(&hash) {
44 state.status = format!(
45 "Downloading {}...",
46 node.node.name
47 );
48 } else {
49 state.status =
50 "Sync not ready — open the Sync panel first".to_string();
51 }
52 ui.close_menu();
53 }
54 }
55 }
56 ui.separator();
57 }
58 if !node.cloud_only && ui.button("Preview").clicked() {
59 if let Some(hash) = &node.node.sample_hash {
60 let hash = hash.clone();
61 state.trigger_preview(&hash);
62 }
63 ui.close_menu();
64 }
65 if ui.button("Copy Path").clicked() {
66 if let Some(path) = state.selected_sample_path() {
67 state.status = format!("Copied: {path}");
68 ui.ctx().copy_text(path);
69 }
70 ui.close_menu();
71 }
72 // M-6: one-click jump to the file in the system file manager.
73 // macOS / Windows highlight the file itself; Linux falls back to
74 // opening the parent directory (no widely-supported select flag).
75 #[cfg(target_os = "macos")]
76 let reveal_label = "Reveal in Finder";
77 #[cfg(target_os = "windows")]
78 let reveal_label = "Show in Explorer";
79 #[cfg(target_os = "linux")]
80 let reveal_label = "Open Containing Folder";
81 if !node.cloud_only && ui.button(reveal_label).clicked() {
82 if let Some(path) = state.selected_sample_path() {
83 #[cfg(target_os = "macos")]
84 let _ = std::process::Command::new("open").args(["-R", &path]).spawn();
85 #[cfg(target_os = "windows")]
86 let _ = std::process::Command::new("explorer")
87 .arg(format!("/select,{}", path))
88 .spawn();
89 #[cfg(target_os = "linux")]
90 {
91 let parent = std::path::Path::new(&path)
92 .parent()
93 .map(|p| p.to_path_buf())
94 .unwrap_or_else(|| std::path::PathBuf::from(&path));
95 let _ = std::process::Command::new("xdg-open").arg(&parent).spawn();
96 }
97 }
98 ui.close_menu();
99 }
100 if ui.button("Find Similar (Shift+F)").clicked() {
101 if let Some(hash) = &node.node.sample_hash {
102 let hash = hash.clone();
103 state.find_similar(&hash);
104 }
105 ui.close_menu();
106 }
107 if ui.button("Find Duplicates (Shift+D)").clicked() {
108 if let Some(hash) = &node.node.sample_hash {
109 let hash = hash.clone();
110 state.find_near_duplicates(&hash);
111 }
112 ui.close_menu();
113 }
114 // Add to Collection submenu
115 if let Some(hash) = &node.node.sample_hash {
116 let hash_clone = hash.clone();
117 let collections = state.collections.clone();
118 let is_in_collection = state.active_collection.is_some();
119 if !collections.is_empty() {
120 ui.menu_button("Add to Collection", |ui| {
121 for coll in &collections {
122 if ui.button(&coll.name).clicked() {
123 let _ = state.backend.add_to_collection(coll.id, &hash_clone);
124 state.refresh_collections();
125 state.status = format!("Added to {}", coll.name);
126 ui.close_menu();
127 }
128 }
129 });
130 }
131 if is_in_collection {
132 if let Some(active_id) = state.active_collection {
133 if widgets::danger_button(ui, "Remove from Collection").clicked() {
134 let _ = state.backend.remove_from_collection(active_id, &hash_clone);
135 state.refresh_collections();
136 state.activate_collection(active_id);
137 ui.close_menu();
138 }
139 }
140 }
141 }
142 if !node.cloud_only {
143 if let Some(hash) = &node.node.sample_hash {
144 let hash_clone = hash.clone();
145 if ui.button("Edit... (E)").clicked() {
146 state.open_edit_window(&hash_clone);
147 ui.close_menu();
148 }
149 }
150 if ui.button("Play as Instrument").clicked() {
151 if let Some(hash) = &node.node.sample_hash {
152 let hash = hash.clone();
153 let name = node.node.name.clone();
154 state.load_chromatic_sample(&hash);
155 state.instrument_visible = true;
156 state.show_midi_window = true;
157 state.status = format!("Instrument: {name}");
158 }
159 ui.close_menu();
160 }
161 if ui.button("Export...").clicked() {
162 state.selection.set_single(row_idx);
163 state.start_export_flow(Some(vec![node.node.id]));
164 ui.close_menu();
165 }
166 // M-7: single-row Re-analyze parity with the multi-row menu.
167 // Reuses ReanalyzeOverwrite with a one-element vec so the
168 // backend path matches the bulk case exactly.
169 if ui
170 .button("Re-analyze...")
171 .on_hover_text("Run analysis again on this sample")
172 .clicked()
173 {
174 if let Some(hash) = &node.node.sample_hash {
175 if let Ok(ext) = state.backend.sample_extension(hash) {
176 let hashes = vec![(hash.to_string(), ext)];
177 let has_existing = node.bpm.is_some()
178 || node.musical_key.is_some()
179 || node.classification.is_some();
180 if has_existing {
181 state.pending_confirm =
182 Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
183 sample_hashes: hashes,
184 overwrite_count: 1,
185 });
186 } else {
187 state.start_analysis_flow(hashes);
188 }
189 }
190 }
191 ui.close_menu();
192 }
193 }
194 ui.separator();
195 if widgets::danger_button(ui, "Delete").clicked() {
196 state.selection.set_single(row_idx);
197 state.confirm_delete_selected();
198 ui.close_menu();
199 }
200 }
201 NodeType::Directory => {
202 if ui.button("Open").clicked() {
203 state.selection.set_single(row_idx);
204 state.enter_directory();
205 ui.close_menu();
206 }
207 if ui.button("New Folder").clicked() {
208 state.show_dir_create = true;
209 state.dir_create_input.clear();
210 ui.close_menu();
211 }
212 if ui.button("Rename").clicked() {
213 state.dir_rename_target = Some((node.node.id, node.node.name.clone()));
214 ui.close_menu();
215 }
216 if ui.button("Export...").clicked() {
217 state.selection.set_single(row_idx);
218 state.start_export_flow(Some(vec![node.node.id]));
219 ui.close_menu();
220 }
221 ui.separator();
222 if widgets::danger_button(ui, "Delete").clicked() {
223 state.selection.set_single(row_idx);
224 state.confirm_delete_selected();
225 ui.close_menu();
226 }
227 }
228 }
229 }
230
231 /// Context menu when multiple items are selected.
232 pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
233 let count = state.selection.count();
234 ui.label(egui::RichText::new(format!("{count} items selected")).strong());
235 ui.separator();
236
237 if ui.button("Invert Selection (Cmd+Shift+I)").clicked() {
238 state.invert_selection();
239 ui.close_menu();
240 }
241
242 ui.separator();
243
244 if ui.button("Tag... (Cmd+T)").clicked() {
245 state.open_bulk_tag_modal();
246 ui.close_menu();
247 }
248 // m-16: Cmd+M conflicts with the macOS minimize-window shortcut. Label
249 // advertises Cmd+Shift+M; the actual key binding lives in
250 // `editor.rs` (search for "Cmd+M: bulk move") and must be updated
251 // there to match.
252 if ui.button("Move to... (Cmd+Shift+M)").clicked() {
253 state.open_bulk_move_modal();
254 ui.close_menu();
255 }
256 if ui.button("Rename... (F2)").clicked() {
257 state.open_bulk_rename_modal();
258 ui.close_menu();
259 }
260 if ui.button("Export...").clicked() {
261 let node_ids = state.selected_node_ids();
262 state.start_export_flow(Some(node_ids));
263 ui.close_menu();
264 }
265
266 // Add to Collection submenu (bulk)
267 let collections = state.collections.clone();
268 if !collections.is_empty() {
269 ui.menu_button("Add to Collection", |ui| {
270 for coll in &collections {
271 if ui.button(&coll.name).clicked() {
272 let nodes = state.selected_nodes();
273 for n in &nodes {
274 if let Some(hash) = &n.node.sample_hash {
275 let _ = state.backend.add_to_collection(coll.id, hash);
276 }
277 }
278 state.refresh_collections();
279 state.status = format!("Added {} items to {}", nodes.len(), coll.name);
280 ui.close_menu();
281 }
282 }
283 });
284 }
285
286 // Remove from Collection (when viewing a collection)
287 if let Some(active_id) = state.active_collection {
288 if widgets::danger_button(ui, "Remove from Collection").clicked() {
289 let nodes = state.selected_nodes();
290 for n in &nodes {
291 if let Some(hash) = &n.node.sample_hash {
292 let _ = state.backend.remove_from_collection(active_id, hash);
293 }
294 }
295 state.refresh_collections();
296 state.activate_collection(active_id);
297 ui.close_menu();
298 }
299 }
300
301 ui.separator();
302
303 if ui.button("Re-analyze...").on_hover_text("Run analysis again on selected samples").clicked() {
304 let selected = state.selected_nodes();
305 let hashes: Vec<(String, String)> = selected
306 .iter()
307 .filter_map(|n| {
308 let hash = n.node.sample_hash.as_ref()?;
309 let ext = state.backend.sample_extension(hash).ok()?;
310 Some((hash.to_string(), ext))
311 })
312 .collect();
313 // Count how many of the selected samples already have computed values
314 // — re-analyzing those will overwrite the previous result, which a user
315 // who hand-tuned the analysis would lose silently otherwise.
316 let overwrite_count = selected
317 .iter()
318 .filter(|n| n.bpm.is_some() || n.musical_key.is_some() || n.classification.is_some())
319 .count();
320 if overwrite_count > 0 {
321 state.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
322 sample_hashes: hashes,
323 overwrite_count,
324 });
325 } else {
326 state.start_analysis_flow(hashes);
327 }
328 ui.close_menu();
329 }
330
331 // Copy tags from focused sample to all selected
332 if let Some(focused) = state.selected_node() {
333 if let Some(ref src_hash) = focused.node.sample_hash {
334 let src_hash = src_hash.clone();
335 let src_name = focused.node.name.clone();
336 if ui.button(format!("Copy Tags from \"{}\"", truncate_name(&src_name, 20)))
337 .on_hover_text("Apply this sample's tags to all other selected samples")
338 .clicked()
339 {
340 let src_hash_str = src_hash.to_string();
341 if let Ok(src_tags) = state.backend.get_sample_tags(&src_hash) {
342 let target_hashes = state.selected_sample_hashes();
343 let mut applied = 0;
344 for hash in &target_hashes {
345 if *hash == src_hash_str { continue; }
346 for tag in &src_tags {
347 let _ = state.backend.add_tag(hash, tag);
348 }
349 applied += 1;
350 }
351 state.status = format!("Copied {} tags to {} samples", src_tags.len(), applied);
352 state.refresh_selected_tags();
353 }
354 ui.close_menu();
355 }
356 }
357 }
358
359 ui.separator();
360
361 if ui.button("Copy Path").clicked() {
362 let nodes = state.selected_nodes();
363 let paths: Vec<String> = nodes
364 .iter()
365 .filter_map(|n| {
366 n.node.sample_hash.as_ref().and_then(|hash| {
367 let ext = state.backend.sample_extension(hash).ok()?;
368 Some(state.backend.sample_path(hash, &ext).ok()?.to_string_lossy().into_owned())
369 })
370 })
371 .collect();
372 if !paths.is_empty() {
373 // Include the first path in the status so the user can recognise the
374 // clipboard contents at a glance — the bare count "Copied N paths"
375 // gave no way to verify which selection won the race when the user
376 // copied, then changed selection, then pasted into a DAW.
377 let first = &paths[0];
378 let count = paths.len();
379 state.status = if count == 1 {
380 format!("Copied: {first}")
381 } else {
382 format!("Copied: {first} (+{} more)", count - 1)
383 };
384 ui.ctx().copy_text(paths.join("\n"));
385 }
386 ui.close_menu();
387 }
388
389 if widgets::danger_button(ui, "Delete").clicked() {
390 state.confirm_delete_selected();
391 ui.close_menu();
392 }
393 }
394
395 /// Context menu for right-clicking empty space in the file list.
396 pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
397 if ui.button("New Folder").clicked() {
398 state.show_dir_create = true;
399 state.dir_create_input.clear();
400 ui.close_menu();
401 }
402 if ui.button("Import files...").clicked() {
403 if let Some(paths) = rfd::FileDialog::new()
404 .set_title("Import files")
405 .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)
406 .pick_files()
407 {
408 for path in paths {
409 state.import_path(&path);
410 }
411 }
412 ui.close_menu();
413 }
414 // C-2: matches the toolbar's "Import folder..." (wizard path). The quick
415 // import shortcut is only offered from the toolbar to keep this menu
416 // short; users who want quick-import find it there.
417 if ui.button("Import folder...").clicked() {
418 if let Some(path) = rfd::FileDialog::new().pick_folder() {
419 state.show_import_options(path);
420 }
421 ui.close_menu();
422 }
423 if state.selection.count() > 0 {
424 ui.separator();
425 let label = format!("Deselect ({}) (Esc)", state.selection.count());
426 if ui.button(label).clicked() {
427 state.selection.clear();
428 state.refresh_selected_tags();
429 state.refresh_selected_detail();
430 ui.close_menu();
431 }
432 if ui.button("Invert Selection (Cmd+Shift+I)").clicked() {
433 state.invert_selection();
434 ui.close_menu();
435 }
436 }
437 }
438
439 /// Truncate a name for display in menus (avoids excessively wide menu items).
440 fn truncate_name(name: &str, max_len: usize) -> String {
441 if name.chars().count() <= max_len {
442 name.to_string()
443 } else {
444 let truncated: String = name.chars().take(max_len.saturating_sub(3)).collect();
445 format!("{truncated}...")
446 }
447 }
448
449 #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
450 pub fn start_os_drag(state: &mut BrowserState) {
451 let nodes = state.selected_nodes();
452 let files: Vec<drag_out::DragFile> = nodes
453 .iter()
454 .filter(|n| n.node.node_type == NodeType::Sample && !n.cloud_only)
455 .filter_map(|n| {
456 let hash = n.node.sample_hash.as_ref()?;
457 let ext = state.backend.sample_extension(hash).ok()?;
458 let store_path = state.backend.sample_path(hash, &ext).ok()?;
459 Some(drag_out::DragFile {
460 friendly_name: n.node.name.clone(),
461 store_path,
462 })
463 })
464 .collect();
465 if !files.is_empty() {
466 let count = files.len();
467 let first = files[0].friendly_name.clone();
468 if drag_out::begin_drag(&files) {
469 state.os_drag_cooldown = Some(std::time::Instant::now());
470 state.status = if count == 1 {
471 format!("Dragged {first}")
472 } else {
473 format!("Dragged {count} samples")
474 };
475 }
476 }
477 }
478