Skip to main content

max / audiofiles

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