Skip to main content

max / audiofiles

browser: edit undo + review-sort + edit history affordance Two browser UX features land together since they share state/ui.rs and state/import_workflow.rs: 1. Edit undo (C-1 part 2). After finalize_edit, stash an EditUndoEntry capturing pre-edit VFS positions so undo_last_edit can walk Replace and Sibling outcomes back. Content-addressed audio data is preserved automatically; undo just reverses the VFS work and drops the matching edit_history row. Inline UI affordance fades out after Edit screen. 2. Review sort (sample analysis screen). ImportMode::ReviewSuggestions now carries a sort knob; added ReviewSort enum + UI controls to the tagging review screen. Plus surface the device-profile category/notes from the prior commit on the export screen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 21:53 UTC
Commit: 7fd99c36a3ad0383f68ab8aeea1b85b62be20e45
Parent: 17c31e7
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 +