Skip to main content

max / audiofiles

24.3 KB · 630 lines History Blame Raw
1 use tracing::warn;
2
3 use super::*;
4
5 impl BrowserState {
6 /// Show the delete confirmation dialog for the current selection.
7 pub fn confirm_delete_selected(&mut self) {
8 if self.selection.count() > 1 {
9 self.confirm_delete_selection();
10 } else if let Some(node) = self.selected_node() {
11 self.pending_confirm = Some(ConfirmAction::DeleteNode {
12 node_id: node.node.id,
13 node_name: node.node.name.clone(),
14 });
15 }
16 }
17
18 /// Execute the pending confirmed action (delete node, delete VFS, or bulk delete).
19 pub fn execute_confirmed_action(&mut self) {
20 // Handle bulk delete separately since it needs &mut self
21 if matches!(&self.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })) {
22 self.execute_bulk_delete();
23 return;
24 }
25
26 let action = self.pending_confirm.take();
27 match action {
28 Some(ConfirmAction::DeleteNode { node_id, node_name }) => {
29 match self.backend.delete_node(node_id) {
30 Ok(()) => {
31 self.refresh_contents();
32 self.status = format!("Deleted \"{node_name}\"");
33 }
34 Err(e) => self.status = format!("Delete failed: {e}"),
35 }
36 }
37 Some(ConfirmAction::DeleteVfs { vfs_id, vfs_name }) => {
38 match self.backend.delete_vfs(vfs_id) {
39 Ok(()) => {
40 // Start background cleanup for orphaned samples
41 if self.backend.start_cleanup().is_ok() {
42 self.import_mode = ImportMode::Cleaning {
43 completed: 0,
44 total: 0,
45 current_name: String::new(),
46 };
47 self.refresh_vfs_list();
48 } else {
49 // Fallback: synchronous cleanup
50 let orphans = self.backend.remove_orphaned_samples().unwrap_or(0);
51 self.refresh_vfs_list();
52 // Note: deleting a VFS row (a sub-vault inside the
53 // current library) is what `delete_vfs` does. The
54 // user-facing word for that inner concept is still
55 // "vault" — we only renamed the registry-level
56 // concept to "library."
57 if orphans > 0 {
58 self.status = format!("Vault \"{vfs_name}\" deleted ({orphans} samples removed)");
59 } else {
60 self.status = format!("Vault \"{vfs_name}\" deleted");
61 }
62 }
63 }
64 Err(e) => self.status = format!("Delete failed: {e}"),
65 }
66 }
67 Some(ConfirmAction::SwitchLibrary { path, .. }) => {
68 // User confirmed despite in-flight work — set the action;
69 // the app layer's SwitchVault handler will tear down the
70 // current browser (cancelling in-flight workers in the process).
71 self.settings.pending_action =
72 Some(crate::state::VaultAction::SwitchVault(path));
73 }
74 Some(ConfirmAction::RemoveTagGlobally { tag }) => {
75 match self.backend.remove_tag_globally(&tag) {
76 Ok(count) => {
77 self.refresh_all_tags();
78 self.search_filter.required_tags.retain(|t| t != &tag);
79 self.apply_search();
80 self.status = format!("Removed tag \"{tag}\" from {count} sample{}",
81 if count == 1 { "" } else { "s" });
82 }
83 Err(e) => self.status = format!("Remove tag failed: {e}"),
84 }
85 }
86 Some(ConfirmAction::DeleteCollection { coll_id, coll_name }) => {
87 match self.backend.delete_collection(coll_id) {
88 Ok(()) => {
89 // Deactivate if the deleted collection was the active one.
90 if self.active_collection == Some(coll_id) {
91 self.deactivate_collection();
92 }
93 self.refresh_collections();
94 self.status = format!("Deleted collection: {coll_name}");
95 }
96 Err(e) => self.status = format!("Delete failed: {e}"),
97 }
98 }
99 Some(ConfirmAction::ReanalyzeOverwrite { sample_hashes, .. }) => {
100 self.start_analysis_flow(sample_hashes);
101 }
102 Some(ConfirmAction::DisconnectSync { .. }) => {
103 // The actual sync.disconnect() happens in sync_panel.rs next
104 // frame — bulk_ops.rs runs without a SyncManager handle.
105 self.sync.pending_disconnect = true;
106 }
107 Some(ConfirmAction::RemoveFailedSamples { single_index, .. }) => {
108 match single_index {
109 Some(idx) => self.remove_failed_sample(idx),
110 None => self.remove_all_failed_samples(),
111 }
112 }
113 Some(ConfirmAction::ReverseSamples { .. }) => {
114 // m-16: confirmed large-batch Reverse. The unconfirmed path
115 // for selections ≤10 calls batch_reverse() directly from the
116 // edit panel; only the gated case routes through here.
117 self.batch_reverse();
118 }
119 Some(ConfirmAction::DeleteMultiple { .. }) => unreachable!(),
120 None => {}
121 }
122 }
123
124 /// Dismiss the confirmation dialog without taking action.
125 pub fn dismiss_confirm(&mut self) {
126 self.pending_confirm = None;
127 }
128
129 // --- Bulk operations ---
130
131 /// All selected nodes, excluding ".." parent entry.
132 pub fn selected_nodes(&self) -> Vec<VfsNodeWithAnalysis> {
133 let offset = if self.current_dir.is_some() { 1 } else { 0 };
134 self.selection
135 .selected
136 .iter()
137 .filter_map(|&idx| {
138 if offset > 0 && idx == 0 {
139 return None; // ".." entry
140 }
141 self.contents.get(idx - offset).cloned()
142 })
143 .collect()
144 }
145
146 /// Node IDs of all selected items, excluding ".." parent entry.
147 pub fn selected_node_ids(&self) -> Vec<NodeId> {
148 let offset = if self.current_dir.is_some() { 1 } else { 0 };
149 self.selection
150 .selected
151 .iter()
152 .filter_map(|&idx| {
153 if offset > 0 && idx == 0 {
154 return None;
155 }
156 self.contents.get(idx - offset).map(|n| n.node.id)
157 })
158 .collect()
159 }
160
161 /// Hashes of all selected samples.
162 pub fn selected_sample_hashes(&self) -> Vec<String> {
163 let offset = if self.current_dir.is_some() { 1 } else { 0 };
164 self.selection
165 .selected
166 .iter()
167 .filter_map(|&idx| {
168 if offset > 0 && idx == 0 {
169 return None;
170 }
171 self.contents
172 .get(idx - offset)
173 .and_then(|n| n.node.sample_hash.as_ref().map(|h| h.to_string()))
174 })
175 .collect()
176 }
177
178 /// Push an undoable operation onto the stack (capped at 100 entries).
179 pub(crate) fn push_undo(&mut self, op: UndoOp) {
180 self.undo_stack.push(op);
181 // Cap at 100 entries to bound memory usage. Each UndoOp can hold a full
182 // subtree snapshot (nodes + tags), so unbounded growth is not acceptable.
183 if self.undo_stack.len() > 100 {
184 let excess = self.undo_stack.len() - 100;
185 self.undo_stack.drain(..excess);
186 }
187 }
188
189 /// Whether there is an undoable operation on the stack.
190 pub fn can_undo(&self) -> bool {
191 !self.undo_stack.is_empty()
192 }
193
194 /// Pop and reverse the most recent bulk operation (delete, move, rename, or tag change).
195 pub fn undo(&mut self) {
196 let op = match self.undo_stack.pop() {
197 Some(op) => op,
198 None => return,
199 };
200 match op {
201 UndoOp::BulkDelete { nodes, tags: tag_data } => {
202 for node in &nodes {
203 let _ = self.backend.restore_node(node);
204 }
205 for (hash, sample_tags) in &tag_data {
206 for tag in sample_tags {
207 let _ = self.backend.add_tag(hash, tag);
208 }
209 }
210 self.status = "Undo: restored deleted items".to_string();
211 }
212 UndoOp::BulkMove { moves } => {
213 for (node_id, old_parent) in &moves {
214 let _ = self.backend.move_node(*node_id, *old_parent);
215 }
216 self.status = "Undo: moved items back".to_string();
217 }
218 UndoOp::BulkRename { renames } => {
219 for (node_id, old_name) in &renames {
220 let _ = self.backend.rename_node(*node_id, old_name);
221 }
222 self.status = "Undo: restored original names".to_string();
223 }
224 UndoOp::BulkTagAdd { tag, hashes } => {
225 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
226 let _ = self.backend.bulk_remove_tag(&refs, &tag);
227 self.status = "Undo: removed tag".to_string();
228 }
229 UndoOp::BulkTagRemove { tag, hashes } => {
230 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
231 let _ = self.backend.bulk_add_tag(&refs, &tag);
232 self.status = "Undo: re-added tag".to_string();
233 }
234 UndoOp::TagRemove { hash, tag } => {
235 let _ = self.backend.add_tag(&hash, &tag);
236 self.status = format!("Undo: restored tag \"{tag}\"");
237 }
238 }
239 self.refresh_contents();
240 self.refresh_all_tags();
241 self.refresh_selected_tags();
242 }
243
244 /// Close the active bulk operation modal without applying changes.
245 pub fn close_bulk_modal(&mut self) {
246 self.bulk_modal = None;
247 }
248
249 /// Open the bulk tag add/remove modal for the current selection.
250 pub fn open_bulk_tag_modal(&mut self) {
251 let nodes = self.selected_nodes();
252 let hashes = self.selected_sample_hashes();
253 if hashes.is_empty() {
254 return;
255 }
256 let names: Vec<String> = nodes
257 .iter()
258 .filter(|n| n.node.sample_hash.is_some())
259 .map(|n| n.node.name.clone())
260 .collect();
261 self.bulk_modal = Some(BulkModal::Tag {
262 tag_input: String::new(),
263 adding: true,
264 hashes,
265 names,
266 });
267 }
268
269 /// Open the bulk move modal, listing all directories as potential targets.
270 pub fn open_bulk_move_modal(&mut self) {
271 let nodes = self.selected_nodes();
272 if nodes.is_empty() {
273 return;
274 }
275 let node_ids: Vec<NodeId> = nodes.iter().map(|n| n.node.id).collect();
276 let names: Vec<String> = nodes.iter().map(|n| n.node.name.clone()).collect();
277 let Some(vfs_id) = self.current_vfs_id() else { return };
278 let directories = self.backend.list_all_directories(vfs_id).unwrap_or_else(|e| {
279 warn!("Failed to list directories: {e}");
280 Vec::new()
281 });
282 self.bulk_modal = Some(BulkModal::Move {
283 node_ids,
284 names,
285 directories,
286 selected_idx: None,
287 });
288 }
289
290 /// Open the bulk rename modal with a pattern input and live previews.
291 pub fn open_bulk_rename_modal(&mut self) {
292 let nodes = self.selected_nodes();
293 if nodes.is_empty() {
294 return;
295 }
296 let targets: Vec<RenameTarget> = nodes
297 .iter()
298 .enumerate()
299 .map(|(i, n)| {
300 let (stem, ext) = split_name_ext(&n.node.name);
301 RenameTarget {
302 node_id: n.node.id,
303 context: audiofiles_core::rename::RenameContext {
304 name: stem,
305 extension: ext,
306 bpm: n.bpm,
307 musical_key: n.musical_key.clone(),
308 classification: n.classification.clone(),
309 duration: n.duration,
310 index: i,
311 },
312 }
313 })
314 .collect();
315 self.bulk_modal = Some(BulkModal::Rename {
316 pattern_input: "{name}".to_string(),
317 targets,
318 previews: Vec::new(),
319 error: None,
320 });
321 }
322
323 /// Apply the bulk tag operation (add or remove) and push an undo entry.
324 pub fn execute_bulk_tag(&mut self) {
325 let (tag_input, adding, hashes) = match &self.bulk_modal {
326 Some(BulkModal::Tag {
327 tag_input,
328 adding,
329 hashes,
330 ..
331 }) => (tag_input.clone(), *adding, hashes.clone()),
332 _ => return,
333 };
334
335 let tag = tag_input.trim();
336 if tag.is_empty() {
337 return;
338 }
339
340 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
341 if adding {
342 match self.backend.bulk_add_tag(&refs, tag) {
343 Ok(count) => {
344 self.push_undo(UndoOp::BulkTagAdd {
345 tag: tag.to_string(),
346 hashes,
347 });
348 self.status = format!("Tagged {count} samples with \"{tag}\"");
349 }
350 Err(e) => {
351 self.status = format!("Tag error: {e}");
352 }
353 }
354 } else {
355 match self.backend.bulk_remove_tag(&refs, tag) {
356 Ok(count) => {
357 self.push_undo(UndoOp::BulkTagRemove {
358 tag: tag.to_string(),
359 hashes,
360 });
361 self.status = format!("Removed tag \"{tag}\" from {count} samples");
362 }
363 Err(e) => {
364 self.status = format!("Tag error: {e}");
365 }
366 }
367 }
368 self.bulk_modal = None;
369 self.refresh_selected_tags();
370 self.refresh_all_tags();
371 }
372
373 /// Apply a tag to an arbitrary list of sample hashes and push an undo entry.
374 /// Used by the multi-summary's right-click "Apply to remaining" affordance
375 /// (M-11), which targets the subset that doesn't yet carry the tag.
376 pub fn apply_tag_to_hashes(&mut self, tag: &str, hashes: &[String]) {
377 if hashes.is_empty() || tag.is_empty() {
378 return;
379 }
380 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
381 match self.backend.bulk_add_tag(&refs, tag) {
382 Ok(count) => {
383 self.push_undo(UndoOp::BulkTagAdd {
384 tag: tag.to_string(),
385 hashes: hashes.to_vec(),
386 });
387 self.status = format!("Tagged {count} samples with \"{tag}\"");
388 }
389 Err(e) => {
390 self.status = format!("Tag error: {e}");
391 }
392 }
393 self.refresh_selected_tags();
394 self.refresh_all_tags();
395 }
396
397 /// Remove a tag from an arbitrary list of sample hashes and push an undo entry.
398 /// Companion to `apply_tag_to_hashes` for the multi-summary badges (M-11).
399 pub fn remove_tag_from_hashes(&mut self, tag: &str, hashes: &[String]) {
400 if hashes.is_empty() || tag.is_empty() {
401 return;
402 }
403 let refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect();
404 match self.backend.bulk_remove_tag(&refs, tag) {
405 Ok(count) => {
406 self.push_undo(UndoOp::BulkTagRemove {
407 tag: tag.to_string(),
408 hashes: hashes.to_vec(),
409 });
410 self.status = format!("Removed tag \"{tag}\" from {count} samples");
411 }
412 Err(e) => {
413 self.status = format!("Tag error: {e}");
414 }
415 }
416 self.refresh_selected_tags();
417 self.refresh_all_tags();
418 }
419
420 /// Move all selected nodes to the chosen directory. Snapshots old parents for undo.
421 pub fn execute_bulk_move(&mut self) {
422 let (node_ids, selected_idx, directories) = match &self.bulk_modal {
423 Some(BulkModal::Move {
424 node_ids,
425 selected_idx,
426 directories,
427 ..
428 }) => (node_ids.clone(), *selected_idx, directories.clone()),
429 _ => return,
430 };
431
432 let target_parent = selected_idx.map(|idx| directories[idx].0);
433
434 let mut moves = Vec::new();
435 for &node_id in &node_ids {
436 if let Ok(node) = self.backend.get_node(node_id) {
437 moves.push((node_id, node.parent_id));
438 let _ = self.backend.move_node(node_id, target_parent);
439 }
440 }
441
442 self.push_undo(UndoOp::BulkMove { moves });
443 let dest_name = selected_idx
444 .map(|idx| directories[idx].1.as_str())
445 .unwrap_or("root");
446 self.status = format!("Moved {} items to {}", node_ids.len(), dest_name);
447 self.bulk_modal = None;
448 self.refresh_contents();
449 }
450
451 /// Apply the rename pattern to all selected nodes. Snapshots old names for undo.
452 pub fn execute_bulk_rename(&mut self) {
453 let (pattern_input, targets) = match &self.bulk_modal {
454 Some(BulkModal::Rename {
455 pattern_input,
456 targets,
457 ..
458 }) => (pattern_input.clone(), targets),
459 _ => return,
460 };
461
462 let pattern = match audiofiles_core::rename::RenamePattern::parse(&pattern_input) {
463 Ok(p) => p,
464 Err(e) => {
465 self.status = format!("Rename error: {e}");
466 return;
467 }
468 };
469
470 let new_stems = pattern.resolve_all(
471 &targets
472 .iter()
473 .map(|t| audiofiles_core::rename::RenameContext {
474 name: t.context.name.clone(),
475 extension: t.context.extension.clone(),
476 bpm: t.context.bpm,
477 musical_key: t.context.musical_key.clone(),
478 classification: t.context.classification.clone(),
479 duration: t.context.duration,
480 index: t.context.index,
481 })
482 .collect::<Vec<_>>(),
483 );
484
485 let mut renames = Vec::new();
486 for (target, new_stem) in targets.iter().zip(new_stems.iter()) {
487 if let Ok(node) = self.backend.get_node(target.node_id) {
488 let new_name = if target.context.extension.is_empty() {
489 new_stem.clone()
490 } else {
491 format!("{}.{}", new_stem, target.context.extension)
492 };
493 renames.push((target.node_id, node.name.clone()));
494 let _ = self.backend.rename_node(target.node_id, &new_name);
495 }
496 }
497
498 self.push_undo(UndoOp::BulkRename { renames });
499 self.status = format!(
500 "Renamed {} items (pattern: {})",
501 new_stems.len(),
502 pattern_input
503 );
504 self.bulk_modal = None;
505 self.refresh_contents();
506 }
507
508 /// Delete all confirmed nodes, snapshotting the full subtree and tags for undo.
509 pub fn execute_bulk_delete(&mut self) {
510 let node_ids = match &self.pending_confirm {
511 Some(ConfirmAction::DeleteMultiple { node_ids, .. }) => node_ids.clone(),
512 _ => return,
513 };
514 self.pending_confirm = None;
515
516 // Snapshot for undo
517 let mut all_nodes = Vec::new();
518 let mut all_tags = Vec::new();
519 for &node_id in &node_ids {
520 if let Ok(subtree) = self.backend.collect_subtree(node_id) {
521 for node in &subtree {
522 if let Some(ref hash) = node.sample_hash {
523 let sample_tags = self.backend.get_sample_tags(hash).unwrap_or_default();
524 if !sample_tags.is_empty() {
525 all_tags.push((hash.to_string(), sample_tags));
526 }
527 }
528 }
529 all_nodes.extend(subtree);
530 }
531 }
532
533 // Delete
534 for &node_id in &node_ids {
535 let _ = self.backend.delete_node(node_id);
536 }
537
538 self.push_undo(UndoOp::BulkDelete {
539 nodes: all_nodes,
540 tags: all_tags,
541 });
542 self.status = format!("Deleted {} items", node_ids.len());
543 self.refresh_contents();
544 }
545
546 /// Build a delete confirmation for the current multi-selection.
547 pub fn confirm_delete_selection(&mut self) {
548 let nodes = self.selected_nodes();
549 if nodes.is_empty() {
550 return;
551 }
552 if nodes.len() == 1 {
553 self.pending_confirm = Some(ConfirmAction::DeleteNode {
554 node_id: nodes[0].node.id,
555 node_name: nodes[0].node.name.clone(),
556 });
557 } else {
558 let node_ids: Vec<NodeId> = nodes.iter().map(|n| n.node.id).collect();
559 let count = node_ids.len();
560 self.pending_confirm = Some(ConfirmAction::DeleteMultiple { node_ids, count });
561 }
562 }
563
564 /// Update rename previews when pattern input changes.
565 pub fn update_rename_previews(&mut self) {
566 if let Some(BulkModal::Rename {
567 ref pattern_input,
568 ref targets,
569 ref mut previews,
570 ref mut error,
571 }) = self.bulk_modal
572 {
573 match audiofiles_core::rename::RenamePattern::parse(pattern_input) {
574 Ok(pattern) => {
575 let contexts: Vec<audiofiles_core::rename::RenameContext> = targets
576 .iter()
577 .map(|t| audiofiles_core::rename::RenameContext {
578 name: t.context.name.clone(),
579 extension: t.context.extension.clone(),
580 bpm: t.context.bpm,
581 musical_key: t.context.musical_key.clone(),
582 classification: t.context.classification.clone(),
583 duration: t.context.duration,
584 index: t.context.index,
585 })
586 .collect();
587 let new_stems = pattern.resolve_all(&contexts);
588 *previews = targets
589 .iter()
590 .zip(new_stems.iter())
591 .map(|(t, new)| {
592 let old = if t.context.extension.is_empty() {
593 t.context.name.clone()
594 } else {
595 format!("{}.{}", t.context.name, t.context.extension)
596 };
597 let new_full = if t.context.extension.is_empty() {
598 new.clone()
599 } else {
600 format!("{}.{}", new, t.context.extension)
601 };
602 (old, new_full)
603 })
604 .collect();
605
606 // Validate: no empty names
607 if previews.iter().any(|(_, new)| new.trim().is_empty()) {
608 *error = Some("Rename would produce empty file names".to_string());
609 return;
610 }
611
612 // Validate: no duplicates
613 let mut seen = std::collections::HashSet::new();
614 let has_dupes = previews.iter().any(|(_, new)| !seen.insert(new.to_lowercase()));
615 if has_dupes {
616 *error = Some("Rename would produce duplicate file names".to_string());
617 return;
618 }
619
620 *error = None;
621 }
622 Err(e) => {
623 previews.clear();
624 *error = Some(e.to_string());
625 }
626 }
627 }
628 }
629 }
630