max / audiofiles
8 files changed,
+415 insertions,
-22 deletions
| @@ -940,6 +940,27 @@ impl Backend for DirectBackend { | |||
| 940 | 940 | Ok(()) | |
| 941 | 941 | } | |
| 942 | 942 | ||
| 943 | + | fn delete_edit_history( | |
| 944 | + | &self, | |
| 945 | + | source_hash: &str, | |
| 946 | + | result_hash: &str, | |
| 947 | + | ) -> BackendResult<()> { | |
| 948 | + | let db = self.db.lock(); | |
| 949 | + | db.conn() | |
| 950 | + | .execute( | |
| 951 | + | "DELETE FROM edit_history | |
| 952 | + | WHERE id = ( | |
| 953 | + | SELECT id FROM edit_history | |
| 954 | + | WHERE source_hash = ?1 AND result_hash = ?2 | |
| 955 | + | ORDER BY id DESC | |
| 956 | + | LIMIT 1 | |
| 957 | + | )", | |
| 958 | + | rusqlite::params![source_hash, result_hash], | |
| 959 | + | ) | |
| 960 | + | .map_err(audiofiles_core::error::CoreError::Db)?; | |
| 961 | + | Ok(()) | |
| 962 | + | } | |
| 963 | + | ||
| 943 | 964 | fn storage_stats(&self) -> BackendResult<super::StorageStats> { | |
| 944 | 965 | let db = self.db.lock(); | |
| 945 | 966 | let (sample_count, total_bytes) = db.storage_stats() |
| @@ -466,6 +466,15 @@ pub trait Backend: Send + Sync { | |||
| 466 | 466 | operation: &EditOperation, | |
| 467 | 467 | ) -> BackendResult<()>; | |
| 468 | 468 | ||
| 469 | + | /// Delete the most recent `edit_history` row matching this (source, result) | |
| 470 | + | /// pair. Used by `BrowserState::undo_last_edit` to reverse a previously | |
| 471 | + | /// recorded edit when the user clicks the inline Undo affordance. | |
| 472 | + | fn delete_edit_history( | |
| 473 | + | &self, | |
| 474 | + | source_hash: &str, | |
| 475 | + | result_hash: &str, | |
| 476 | + | ) -> BackendResult<()>; | |
| 477 | + | ||
| 469 | 478 | /// Get aggregate storage statistics for the current vault. | |
| 470 | 479 | fn storage_stats(&self) -> BackendResult<StorageStats>; | |
| 471 | 480 |
| @@ -583,6 +583,7 @@ impl BrowserState { | |||
| 583 | 583 | self.import_mode = ImportMode::ReviewSuggestions { | |
| 584 | 584 | items, | |
| 585 | 585 | current_idx: 0, | |
| 586 | + | sort: crate::state::ReviewSort::ImportOrder, | |
| 586 | 587 | }; | |
| 587 | 588 | self.status = if error_count == 0 { | |
| 588 | 589 | format!("Analyzed {count} samples") | |
| @@ -1309,6 +1310,12 @@ impl BrowserState { | |||
| 1309 | 1310 | ||
| 1310 | 1311 | let new_ext = self.backend.sample_extension(&new_hash).unwrap_or_else(|_| "wav".to_string()); | |
| 1311 | 1312 | ||
| 1313 | + | // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit` | |
| 1314 | + | // can walk them back. Captured before deletion in Replace mode and | |
| 1315 | + | // after creation in Sibling mode. | |
| 1316 | + | let mut replace_targets: Vec<(Option<audiofiles_core::NodeId>, String)> = Vec::new(); | |
| 1317 | + | let mut sibling_node_id: Option<audiofiles_core::NodeId> = None; | |
| 1318 | + | ||
| 1312 | 1319 | match mode { | |
| 1313 | 1320 | super::EditResultMode::Replace => { | |
| 1314 | 1321 | // Update each VFS node that references the source hash | |
| @@ -1316,6 +1323,7 @@ impl BrowserState { | |||
| 1316 | 1323 | // Delete old node and create new one with same name/parent | |
| 1317 | 1324 | let parent_id = node.node.parent_id; | |
| 1318 | 1325 | let name = node.node.name.clone(); | |
| 1326 | + | replace_targets.push((parent_id, name.clone())); | |
| 1319 | 1327 | let _ = self.backend.delete_node(node.node.id); | |
| 1320 | 1328 | let _ = self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash); | |
| 1321 | 1329 | } | |
| @@ -1326,11 +1334,31 @@ impl BrowserState { | |||
| 1326 | 1334 | let parent_id = node.node.parent_id; | |
| 1327 | 1335 | let (stem, _ext) = split_name_ext(&node.node.name); | |
| 1328 | 1336 | let sibling_name = format!("{stem}_edited.{new_ext}"); | |
| 1329 | - | let _ = self.backend.create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash); | |
| 1337 | + | if let Ok(id) = self | |
| 1338 | + | .backend | |
| 1339 | + | .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash) | |
| 1340 | + | { | |
| 1341 | + | sibling_node_id = Some(id); | |
| 1342 | + | } | |
| 1330 | 1343 | } | |
| 1331 | 1344 | } | |
| 1332 | 1345 | } | |
| 1333 | 1346 | ||
| 1347 | + | // C-1 part 2: stash the entry only if there's actually something to | |
| 1348 | + | // undo (no VFS nodes → nothing happened that needs reversing). | |
| 1349 | + | if !replace_targets.is_empty() || sibling_node_id.is_some() { | |
| 1350 | + | self.edit.last_undo = Some(super::EditUndoEntry { | |
| 1351 | + | op_name: operation.display_name().to_string(), | |
| 1352 | + | source_hash: source_hash.clone(), | |
| 1353 | + | result_hash: new_hash.clone(), | |
| 1354 | + | mode, | |
| 1355 | + | vfs_id, | |
| 1356 | + | replace_targets, | |
| 1357 | + | sibling_node_id, | |
| 1358 | + | created_at: std::time::Instant::now(), | |
| 1359 | + | }); | |
| 1360 | + | } | |
| 1361 | + | ||
| 1334 | 1362 | // 4. Trigger analysis on the new sample | |
| 1335 | 1363 | let hashes = vec![(new_hash, new_ext)]; | |
| 1336 | 1364 | let _ = self.backend.start_analysis(hashes, AnalysisConfig::default()); | |
| @@ -1338,4 +1366,56 @@ impl BrowserState { | |||
| 1338 | 1366 | // 5. Refresh the file list | |
| 1339 | 1367 | self.refresh_contents(); | |
| 1340 | 1368 | } | |
| 1369 | + | ||
| 1370 | + | /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks | |
| 1371 | + | /// the VFS nodes back to the source hash; Sibling mode deletes the | |
| 1372 | + | /// created sibling. The original sample blob is preserved by the | |
| 1373 | + | /// content-addressed store so no audio data needs to be restored. | |
| 1374 | + | /// | |
| 1375 | + | /// Returns silently with no-op if `last_undo` is empty. | |
| 1376 | + | pub fn undo_last_edit(&mut self) { | |
| 1377 | + | let Some(entry) = self.edit.last_undo.take() else { | |
| 1378 | + | return; | |
| 1379 | + | }; | |
| 1380 | + | ||
| 1381 | + | match entry.mode { | |
| 1382 | + | super::EditResultMode::Replace => { | |
| 1383 | + | // Re-find any nodes now pointing at result_hash and clear them | |
| 1384 | + | // first — the edit may have produced multiple nodes when the | |
| 1385 | + | // sample appeared in multiple places, and we recreate from the | |
| 1386 | + | // captured (parent_id, name) list. | |
| 1387 | + | let result_hashes = [entry.result_hash.as_str()]; | |
| 1388 | + | if let Ok(result_nodes) = self | |
| 1389 | + | .backend | |
| 1390 | + | .find_nodes_by_hashes(entry.vfs_id, &result_hashes) | |
| 1391 | + | { | |
| 1392 | + | for node in &result_nodes { | |
| 1393 | + | let _ = self.backend.delete_node(node.node.id); | |
| 1394 | + | } | |
| 1395 | + | } | |
| 1396 | + | for (parent_id, name) in &entry.replace_targets { | |
| 1397 | + | let _ = self.backend.create_sample_link( | |
| 1398 | + | entry.vfs_id, | |
| 1399 | + | *parent_id, | |
| 1400 | + | name, | |
| 1401 | + | &entry.source_hash, | |
| 1402 | + | ); | |
| 1403 | + | } | |
| 1404 | + | } | |
| 1405 | + | super::EditResultMode::Sibling => { | |
| 1406 | + | if let Some(id) = entry.sibling_node_id { | |
| 1407 | + | let _ = self.backend.delete_node(id); | |
| 1408 | + | } | |
| 1409 | + | } | |
| 1410 | + | } | |
| 1411 | + | ||
| 1412 | + | // Drop the matching edit_history row so the audit trail reflects the | |
| 1413 | + | // undo. Best-effort — the UI undo affordance already disappeared. | |
| 1414 | + | let _ = self | |
| 1415 | + | .backend | |
| 1416 | + | .delete_edit_history(&entry.source_hash, &entry.result_hash); | |
| 1417 | + | ||
| 1418 | + | self.status = format!("Reverted {}", entry.op_name); | |
| 1419 | + | self.refresh_contents(); | |
| 1420 | + | } | |
| 1341 | 1421 | } |
| @@ -887,6 +887,7 @@ mod import_and_analysis { | |||
| 887 | 887 | state.import_mode = ImportMode::ReviewSuggestions { | |
| 888 | 888 | items: vec![], | |
| 889 | 889 | current_idx: 0, | |
| 890 | + | sort: crate::state::ReviewSort::ImportOrder, | |
| 890 | 891 | }; | |
| 891 | 892 | state.apply_accepted_suggestions(); | |
| 892 | 893 | assert!(matches!(state.import_mode, ImportMode::None)); | |
| @@ -1709,4 +1710,107 @@ mod misc { | |||
| 1709 | 1710 | state.execute_confirmed_action(); | |
| 1710 | 1711 | assert_eq!(state.vfs_list.len(), initial_count - 1); | |
| 1711 | 1712 | } | |
| 1713 | + | ||
| 1714 | + | // C-1 part 2: inline Undo for the most recent edit. | |
| 1715 | + | ||
| 1716 | + | #[test] | |
| 1717 | + | fn undo_last_edit_replace_restores_source_node() { | |
| 1718 | + | use crate::state::{EditResultMode, EditUndoEntry}; | |
| 1719 | + | ||
| 1720 | + | let (mut state, _dir) = make_state(); | |
| 1721 | + | // Simulate the post-Replace VFS state: source node was deleted, a new | |
| 1722 | + | // node pointing at result_hash now sits in its place. | |
| 1723 | + | let source_hash = "src0"; | |
| 1724 | + | let result_hash = "res0"; | |
| 1725 | + | insert_fake_sample(&state, source_hash); | |
| 1726 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 1727 | + | let parent_id = state.current_dir; | |
| 1728 | + | let _result_node_id = add_sample_to_vfs(&state, result_hash, "kick.wav"); | |
| 1729 | + | ||
| 1730 | + | // Stash the entry as if finalize_edit had run. | |
| 1731 | + | state.edit.last_undo = Some(EditUndoEntry { | |
| 1732 | + | op_name: "Trim".to_string(), | |
| 1733 | + | source_hash: source_hash.to_string(), | |
| 1734 | + | result_hash: result_hash.to_string(), | |
| 1735 | + | mode: EditResultMode::Replace, | |
| 1736 | + | vfs_id, | |
| 1737 | + | replace_targets: vec![(parent_id, "kick.wav".to_string())], | |
| 1738 | + | sibling_node_id: None, | |
| 1739 | + | created_at: std::time::Instant::now(), | |
| 1740 | + | }); | |
| 1741 | + | ||
| 1742 | + | // Sanity: result_hash is in the VFS before undo. | |
| 1743 | + | assert_eq!( | |
| 1744 | + | state.backend.find_nodes_by_hashes(vfs_id, &[result_hash]).unwrap().len(), | |
| 1745 | + | 1, | |
| 1746 | + | ); | |
| 1747 | + | ||
| 1748 | + | state.undo_last_edit(); | |
| 1749 | + | ||
| 1750 | + | // SQLite reuses rowids after delete, so the original `result_node_id` | |
| 1751 | + | // may now point at the recreated source node. Assert via hash lookup | |
| 1752 | + | // instead: no node references result_hash, exactly one references | |
| 1753 | + | // source_hash, and the recreated node keeps the original name. | |
| 1754 | + | assert!(state | |
| 1755 | + | .backend | |
| 1756 | + | .find_nodes_by_hashes(vfs_id, &[result_hash]) | |
| 1757 | + | .unwrap() | |
| 1758 | + | .is_empty()); | |
| 1759 | + | let restored = state | |
| 1760 | + | .backend | |
| 1761 | + | .find_nodes_by_hashes(vfs_id, &[source_hash]) | |
| 1762 | + | .unwrap(); | |
| 1763 | + | assert_eq!(restored.len(), 1); | |
| 1764 | + | assert_eq!(restored[0].node.name, "kick.wav"); | |
| 1765 | + | assert!(state.edit.last_undo.is_none()); | |
| 1766 | + | assert_eq!(state.status, "Reverted Trim"); | |
| 1767 | + | } | |
| 1768 | + | ||
| 1769 | + | #[test] | |
| 1770 | + | fn undo_last_edit_sibling_removes_sibling_node() { | |
| 1771 | + | use crate::state::{EditResultMode, EditUndoEntry}; | |
| 1772 | + | ||
| 1773 | + | let (mut state, _dir) = make_state(); | |
| 1774 | + | let source_hash = "src1"; | |
| 1775 | + | let result_hash = "res1"; | |
| 1776 | + | let _src_node = add_sample_to_vfs(&state, source_hash, "snare.wav"); | |
| 1777 | + | let sibling_node_id = add_sample_to_vfs(&state, result_hash, "snare_edited.wav"); | |
| 1778 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 1779 | + | ||
| 1780 | + | state.edit.last_undo = Some(EditUndoEntry { | |
| 1781 | + | op_name: "Reverse".to_string(), | |
| 1782 | + | source_hash: source_hash.to_string(), | |
| 1783 | + | result_hash: result_hash.to_string(), | |
| 1784 | + | mode: EditResultMode::Sibling, | |
| 1785 | + | vfs_id, | |
| 1786 | + | replace_targets: vec![], | |
| 1787 | + | sibling_node_id: Some(sibling_node_id), | |
| 1788 | + | created_at: std::time::Instant::now(), | |
| 1789 | + | }); | |
| 1790 | + | ||
| 1791 | + | state.undo_last_edit(); | |
| 1792 | + | ||
| 1793 | + | // The sibling is gone; the original survives. | |
| 1794 | + | assert!(state | |
| 1795 | + | .backend | |
| 1796 | + | .find_nodes_by_hashes(vfs_id, &[result_hash]) | |
| 1797 | + | .unwrap() | |
| 1798 | + | .is_empty()); | |
| 1799 | + | let originals = state | |
| 1800 | + | .backend | |
| 1801 | + | .find_nodes_by_hashes(vfs_id, &[source_hash]) | |
| 1802 | + | .unwrap(); | |
| 1803 | + | assert_eq!(originals.len(), 1); | |
| 1804 | + | assert!(state.edit.last_undo.is_none()); | |
| 1805 | + | let _ = sibling_node_id; | |
| 1806 | + | } | |
| 1807 | + | ||
| 1808 | + | #[test] | |
| 1809 | + | fn undo_last_edit_with_no_entry_is_noop() { | |
| 1810 | + | let (mut state, _dir) = make_state(); | |
| 1811 | + | let prior_status = state.status.clone(); | |
| 1812 | + | state.undo_last_edit(); | |
| 1813 | + | assert!(state.edit.last_undo.is_none()); | |
| 1814 | + | assert_eq!(state.status, prior_status); | |
| 1815 | + | } | |
| 1712 | 1816 | } |
| @@ -26,6 +26,31 @@ pub struct PendingEditResult { | |||
| 26 | 26 | pub operation: EditOperation, | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | /// Snapshot of a just-applied edit, used by `BrowserState::undo_last_edit` | |
| 30 | + | /// to reverse the VFS-level work after `finalize_edit` (C-1 part 2). | |
| 31 | + | /// | |
| 32 | + | /// We don't need to snapshot the audio data itself — the content store is | |
| 33 | + | /// content-addressed, so the original `source_hash` blob is preserved when | |
| 34 | + | /// Replace mode re-points VFS nodes. Undo only needs to walk the VFS work | |
| 35 | + | /// back, plus drop the matching `edit_history` row. | |
| 36 | + | pub struct EditUndoEntry { | |
| 37 | + | /// Display name of the operation (e.g. "Trim", "Reverse"). | |
| 38 | + | pub op_name: String, | |
| 39 | + | pub source_hash: String, | |
| 40 | + | pub result_hash: String, | |
| 41 | + | pub mode: EditResultMode, | |
| 42 | + | /// VFS the edit was applied in. | |
| 43 | + | pub vfs_id: VfsId, | |
| 44 | + | /// Pre-edit node positions for Replace mode: `(parent_id, name)`. After | |
| 45 | + | /// undo we recreate one sample link per entry pointing back at | |
| 46 | + | /// `source_hash`. Empty for Sibling mode. | |
| 47 | + | pub replace_targets: Vec<(Option<NodeId>, String)>, | |
| 48 | + | /// For Sibling mode: the result-side node id to delete. None for Replace. | |
| 49 | + | pub sibling_node_id: Option<NodeId>, | |
| 50 | + | /// When the edit landed — drives the inline affordance's fade-out. | |
| 51 | + | pub created_at: Instant, | |
| 52 | + | } | |
| 53 | + | ||
| 29 | 54 | /// Pending destructive action awaiting user confirmation. | |
| 30 | 55 | pub enum ConfirmAction { | |
| 31 | 56 | DeleteNode { node_id: NodeId, node_name: String }, | |
| @@ -345,6 +370,10 @@ pub struct EditUiState { | |||
| 345 | 370 | pub silence_duration_ms: f64, | |
| 346 | 371 | pub remove_start_ms: f64, | |
| 347 | 372 | pub remove_end_ms: f64, | |
| 373 | + | /// C-1 part 2: the most-recent finished edit, while still reversible from | |
| 374 | + | /// the panel. Cleared when the affordance times out (>10s) or another | |
| 375 | + | /// edit overwrites it. None at startup and after an undo. | |
| 376 | + | pub last_undo: Option<EditUndoEntry>, | |
| 348 | 377 | } | |
| 349 | 378 | ||
| 350 | 379 | impl Default for EditUiState { | |
| @@ -369,6 +398,7 @@ impl Default for EditUiState { | |||
| 369 | 398 | silence_duration_ms: 100.0, | |
| 370 | 399 | remove_start_ms: 0.0, | |
| 371 | 400 | remove_end_ms: 100.0, | |
| 401 | + | last_undo: None, | |
| 372 | 402 | } | |
| 373 | 403 | } | |
| 374 | 404 | } | |
| @@ -498,6 +528,37 @@ impl Default for OperationProgress { | |||
| 498 | 528 | } | |
| 499 | 529 | } | |
| 500 | 530 | ||
| 531 | + | /// Sort order for the Review Suggestions sample list (p-3). | |
| 532 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 533 | + | pub enum ReviewSort { | |
| 534 | + | /// Original import order. | |
| 535 | + | ImportOrder, | |
| 536 | + | /// Alphabetical by sample name. | |
| 537 | + | Name, | |
| 538 | + | /// Descending total suggestion count. | |
| 539 | + | Suggestions, | |
| 540 | + | /// Descending accepted count. | |
| 541 | + | Accepted, | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | impl ReviewSort { | |
| 545 | + | pub const ALL: &'static [ReviewSort] = &[ | |
| 546 | + | ReviewSort::ImportOrder, | |
| 547 | + | ReviewSort::Name, | |
| 548 | + | ReviewSort::Suggestions, | |
| 549 | + | ReviewSort::Accepted, | |
| 550 | + | ]; | |
| 551 | + | ||
| 552 | + | pub fn label(self) -> &'static str { | |
| 553 | + | match self { | |
| 554 | + | ReviewSort::ImportOrder => "Import order", | |
| 555 | + | ReviewSort::Name => "Name", | |
| 556 | + | ReviewSort::Suggestions => "Suggestions", | |
| 557 | + | ReviewSort::Accepted => "Accepted", | |
| 558 | + | } | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 501 | 562 | /// Current import/analysis workflow state. | |
| 502 | 563 | pub enum ImportMode { | |
| 503 | 564 | None, | |
| @@ -538,6 +599,10 @@ pub enum ImportMode { | |||
| 538 | 599 | ReviewSuggestions { | |
| 539 | 600 | items: Vec<ReviewItem>, | |
| 540 | 601 | current_idx: usize, | |
| 602 | + | /// p-3: how the sample list in the side panel is ordered. Doesn't | |
| 603 | + | /// reorder `items` — the screen renders through an index map so | |
| 604 | + | /// `current_idx` keeps pointing at the underlying item. | |
| 605 | + | sort: ReviewSort, | |
| 541 | 606 | }, | |
| 542 | 607 | ConfigureExport { | |
| 543 | 608 | items: Vec<audiofiles_core::export::ExportItem>, |
| @@ -485,20 +485,52 @@ fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 485 | 485 | } | |
| 486 | 486 | } | |
| 487 | 487 | }); | |
| 488 | - | // C-1 part 2: Replace is destructive — the original file content is | |
| 489 | - | // overwritten with no in-app undo (edit operations don't push to the | |
| 490 | - | // global undo stack). Surface the consequence here so the user reads it | |
| 491 | - | // before pressing any Apply button. True per-edit undo would need backend | |
| 492 | - | // snapshot support; deferred. | |
| 488 | + | // Replace mode advisory. The previous "no in-app undo" copy was retired | |
| 489 | + | // when the inline Undo affordance landed (C-1 part 2). The hint still | |
| 490 | + | // points the user at Create sibling as the non-destructive default for | |
| 491 | + | // workflows that want to keep the original in the VFS. | |
| 493 | 492 | if matches!(state.edit.result_mode, Some(EditResultMode::Replace)) { | |
| 494 | 493 | ui.label( | |
| 495 | 494 | egui::RichText::new( | |
| 496 | - | "Replace mode: original content is overwritten. Switch to Create sibling to keep the original.", | |
| 495 | + | "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or click Undo on the next line within 10s to revert.", | |
| 497 | 496 | ) | |
| 498 | 497 | .small() | |
| 499 | 498 | .color(theme::accent_yellow()), | |
| 500 | 499 | ); | |
| 501 | 500 | } | |
| 501 | + | ||
| 502 | + | // C-1 part 2: inline Undo for the most recent edit. The content store is | |
| 503 | + | // content-addressed so the original sample blob is preserved on Replace — | |
| 504 | + | // Undo only walks back the VFS work. Times out after 10s to avoid | |
| 505 | + | // surprising the user with a stale affordance after they've moved on. | |
| 506 | + | const UNDO_TIMEOUT_SECS: f32 = 10.0; | |
| 507 | + | let undo_expired = state | |
| 508 | + | .edit | |
| 509 | + | .last_undo | |
| 510 | + | .as_ref() | |
| 511 | + | .map(|e| e.created_at.elapsed().as_secs_f32() > UNDO_TIMEOUT_SECS) | |
| 512 | + | .unwrap_or(false); | |
| 513 | + | if undo_expired { | |
| 514 | + | state.edit.last_undo = None; | |
| 515 | + | } | |
| 516 | + | if let Some(ref entry) = state.edit.last_undo { | |
| 517 | + | let op_label = entry.op_name.clone(); | |
| 518 | + | let remaining = std::time::Duration::from_secs_f32(UNDO_TIMEOUT_SECS) | |
| 519 | + | .saturating_sub(entry.created_at.elapsed()); | |
| 520 | + | // Wake the UI when the affordance is due to expire so it disappears | |
| 521 | + | // even if the user isn't interacting with the panel. | |
| 522 | + | ui.ctx().request_repaint_after(remaining); | |
| 523 | + | ui.horizontal(|ui| { | |
| 524 | + | ui.label( | |
| 525 | + | egui::RichText::new(format!("Last edit: {op_label}")) | |
| 526 | + | .small() | |
| 527 | + | .color(theme::text_muted()), | |
| 528 | + | ); | |
| 529 | + | if ui.small_button("Undo").clicked() { | |
| 530 | + | state.undo_last_edit(); | |
| 531 | + | } | |
| 532 | + | }); | |
| 533 | + | } | |
| 502 | 534 | } | |
| 503 | 535 | ||
| 504 | 536 | /// Draw the "Replace or Create Sibling?" prompt after first edit. |
| @@ -274,13 +274,20 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 274 | 274 | .color(theme::text_muted()), | |
| 275 | 275 | ); | |
| 276 | 276 | } | |
| 277 | - | // p-5: deferred. DeviceProfile carries only name / | |
| 278 | - | // manufacturer / audio / naming / limits today | |
| 279 | - | // (see audiofiles-core::export::profile). Adding a | |
| 280 | - | // muted "category / notes" line would require new | |
| 281 | - | // schema fields on DeviceProfile + DeviceProfileSummary | |
| 282 | - | // and a backfill across the bundled rhai manifests; | |
| 283 | - | // out of scope for the Phase 5 Polish batch. | |
| 277 | + | if let Some(ref category) = profile.category { | |
| 278 | + | ui.label( | |
| 279 | + | egui::RichText::new(category) | |
| 280 | + | .small() | |
| 281 | + | .color(theme::text_muted()), | |
| 282 | + | ); | |
| 283 | + | } | |
| 284 | + | if let Some(ref notes) = profile.notes { | |
| 285 | + | ui.label( | |
| 286 | + | egui::RichText::new(notes) | |
| 287 | + | .small() | |
| 288 | + | .color(theme::text_muted()), | |
| 289 | + | ); | |
| 290 | + | } | |
| 284 | 291 | } | |
| 285 | 292 | } | |
| 286 | 293 | } |
| @@ -3,7 +3,7 @@ use std::collections::BTreeMap; | |||
| 3 | 3 | use egui; | |
| 4 | 4 | use super::super::{theme, widgets}; | |
| 5 | 5 | ||
| 6 | - | use crate::state::{BrowserState, ImportMode}; | |
| 6 | + | use crate::state::{BrowserState, ImportMode, ReviewSort}; | |
| 7 | 7 | use audiofiles_core::tags; | |
| 8 | 8 | ||
| 9 | 9 | const STEPS: &[&str] = &["Configure", "Tag folders", "Analyze", "Review"]; | |
| @@ -137,7 +137,7 @@ pub fn draw_tag_folders(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 137 | 137 | pub fn draw_review_suggestions(ctx: &egui::Context, state: &mut BrowserState) { | |
| 138 | 138 | let (item_count, total_suggestions, accepted_count, _current_idx) = | |
| 139 | 139 | match &state.import_mode { | |
| 140 | - | ImportMode::ReviewSuggestions { items, current_idx } => { | |
| 140 | + | ImportMode::ReviewSuggestions { items, current_idx, .. } => { | |
| 141 | 141 | let total: usize = items.iter().map(|i| i.suggestions.len()).sum(); | |
| 142 | 142 | let accepted: usize = items | |
| 143 | 143 | .iter() | |
| @@ -256,21 +256,65 @@ pub fn draw_review_suggestions(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 256 | 256 | ui.add_space(theme::space::XS); | |
| 257 | 257 | }); | |
| 258 | 258 | ||
| 259 | + | // p-4: ↑/↓ walk the side-panel sample list. Honours the current sort | |
| 260 | + | // order via the index map computed below. Suppressed while a text input | |
| 261 | + | // owns focus so tag typing isn't hijacked. | |
| 262 | + | let nav_delta: i32 = if ctx.memory(|m| m.focused().is_some()) { | |
| 263 | + | 0 | |
| 264 | + | } else { | |
| 265 | + | let up = ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)); | |
| 266 | + | let down = ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)); | |
| 267 | + | match (up, down) { | |
| 268 | + | (true, false) => -1, | |
| 269 | + | (false, true) => 1, | |
| 270 | + | _ => 0, | |
| 271 | + | } | |
| 272 | + | }; | |
| 273 | + | ||
| 259 | 274 | egui::SidePanel::left("review_samples") | |
| 260 | 275 | .resizable(true) | |
| 261 | - | .default_width(200.0) | |
| 276 | + | .default_width(220.0) | |
| 262 | 277 | .show(ctx, |ui| { | |
| 263 | - | ui.label("Samples"); | |
| 264 | - | ui.separator(); | |
| 278 | + | // p-3: sort selector at the top of the side panel. The sort | |
| 279 | + | // doesn't mutate `items` — `display_order` below maps display | |
| 280 | + | // position → item index so `current_idx` keeps pointing at the | |
| 281 | + | // underlying ReviewItem. | |
| 282 | + | if let ImportMode::ReviewSuggestions { ref mut sort, .. } = state.import_mode { | |
| 283 | + | ui.horizontal(|ui| { | |
| 284 | + | ui.label("Sort:"); | |
| 285 | + | egui::ComboBox::from_id_salt("review_sort") | |
| 286 | + | .selected_text(sort.label()) | |
| 287 | + | .show_ui(ui, |ui| { | |
| 288 | + | for option in ReviewSort::ALL { | |
| 289 | + | ui.selectable_value(sort, *option, option.label()); | |
| 290 | + | } | |
| 291 | + | }); | |
| 292 | + | }); | |
| 293 | + | ui.separator(); | |
| 294 | + | } | |
| 265 | 295 | ||
| 266 | 296 | egui::ScrollArea::vertical().show(ui, |ui| { | |
| 267 | 297 | if let ImportMode::ReviewSuggestions { | |
| 268 | 298 | ref items, | |
| 269 | 299 | ref mut current_idx, | |
| 270 | - | .. | |
| 300 | + | sort, | |
| 271 | 301 | } = state.import_mode | |
| 272 | 302 | { | |
| 273 | - | for (i, item) in items.iter().enumerate() { | |
| 303 | + | let display_order = sorted_indices(items, sort); | |
| 304 | + | ||
| 305 | + | if nav_delta != 0 && !display_order.is_empty() { | |
| 306 | + | let pos = display_order | |
| 307 | + | .iter() | |
| 308 | + | .position(|&idx| idx == *current_idx) | |
| 309 | + | .unwrap_or(0) as i32; | |
| 310 | + | let new_pos = (pos + nav_delta) | |
| 311 | + | .clamp(0, display_order.len() as i32 - 1) | |
| 312 | + | as usize; | |
| 313 | + | *current_idx = display_order[new_pos]; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | for &i in &display_order { | |
| 317 | + | let item = &items[i]; | |
| 274 | 318 | let total = item.suggestions.len(); | |
| 275 | 319 | let accepted = item.suggestions.iter().filter(|s| s.accepted).count(); | |
| 276 | 320 | let selected = i == *current_idx; | |
| @@ -295,9 +339,13 @@ pub fn draw_review_suggestions(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 295 | 339 | } else { | |
| 296 | 340 | egui::RichText::new(format!("{}{}", item.name, suffix)).color(color) | |
| 297 | 341 | }; | |
| 298 | - | if ui.selectable_label(selected, label).clicked() { | |
| 342 | + | let response = ui.selectable_label(selected, label); | |
| 343 | + | if response.clicked() { | |
| 299 | 344 | *current_idx = i; | |
| 300 | 345 | } | |
| 346 | + | if selected && nav_delta != 0 { | |
| 347 | + | response.scroll_to_me(Some(egui::Align::Center)); | |
| 348 | + | } | |
| 301 | 349 | } | |
| 302 | 350 | } | |
| 303 | 351 | }); | |
| @@ -386,3 +434,30 @@ pub fn draw_review_suggestions(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 386 | 434 | } | |
| 387 | 435 | }); | |
| 388 | 436 | } | |
| 437 | + | ||
| 438 | + | /// p-3 helper: build a display order over `items` for the requested sort. | |
| 439 | + | /// Returns indices into the original Vec so `current_idx` keeps pointing at | |
| 440 | + | /// the underlying item. | |
| 441 | + | fn sorted_indices( | |
| 442 | + | items: &[crate::state::ReviewItem], | |
| 443 | + | sort: ReviewSort, | |
| 444 | + | ) -> Vec<usize> { | |
| 445 | + | let mut idx: Vec<usize> = (0..items.len()).collect(); | |
| 446 | + | match sort { | |
| 447 | + | ReviewSort::ImportOrder => {} | |
| 448 | + | ReviewSort::Name => { | |
| 449 | + | idx.sort_by(|&a, &b| { | |
| 450 | + | items[a].name.to_lowercase().cmp(&items[b].name.to_lowercase()) | |
| 451 | + | }); | |
| 452 | + | } | |
| 453 | + | ReviewSort::Suggestions => { | |
| 454 | + | idx.sort_by(|&a, &b| items[b].suggestions.len().cmp(&items[a].suggestions.len())); | |
| 455 | + | } | |
| 456 | + | ReviewSort::Accepted => { | |
| 457 | + | let accepted = |i: usize| items[i].suggestions.iter().filter(|s| s.accepted).count(); | |
| 458 | + | idx.sort_by(|&a, &b| accepted(b).cmp(&accepted(a))); | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | idx | |
| 462 | + | } | |
| 463 | + |