Skip to main content

max / audiofiles

UX: move loose-files maintenance off the egui thread, fix stale results (ultra-fuzz) The loose-files integrity check (at vault open) and the Locate/Purge actions ran an unbounded directory walk + SHA-256 verification synchronously on the egui frame thread, freezing the UI for seconds-to-minutes with no progress or cancel. - New loose_files_worker: a background thread (own DB connection) handling CheckIntegrity / Relocate / Purge, mirroring the cleanup worker. Results arrive as BackendEvent::LooseFiles* via poll_events; library.rs dispatches instead of blocking, and the import-workflow poll loop applies the results. - Similarity results are now generation-guarded: each find_similar/find_near_dup request is stamped (hash, kind); a result that doesn't match the latest request is dropped, so a slow query can't replace the view after the user moved on. - poll_workers + the classifier/in-progress repaint nudges are hoisted above the import-mode match, so every screen drains worker channels each frame (a completion event landing in a wizard mode no longer sits unread). - Removed the vestigial storage_scanning flag: it was set and cleared within one frame around a synchronous indexed query, so its spinner could never render. Tests: loose-files worker spawn/drop + integrity-result round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 22:03 UTC
Commit: bd43d675376ecb09102dad1fb612c9b8d4d7e4cf
Parent: 7cc7439
10 files changed, +348 insertions, -60 deletions
@@ -670,7 +670,10 @@ impl AudioFilesApp {
670 670 }
671 671 }
672 672 VaultAction::ScanStorage => {
673 - browser.settings.storage_scanning = true;
673 + // storage_stats() is a single indexed COUNT/SUM — fast
674 + // enough to run inline; no in-progress flag needed (the
675 + // old one was set and cleared in the same frame, so its
676 + // spinner could never render).
674 677 match browser.backend.storage_stats() {
675 678 Ok(stats) => {
676 679 browser.settings.storage_cache = Some(stats);
@@ -683,7 +686,6 @@ impl AudioFilesApp {
683 686 }
684 687 Err(e) => browser.status = format!("Storage scan failed: {e}"),
685 688 }
686 - browser.settings.storage_scanning = false;
687 689 }
688 690 VaultAction::DeactivateLicense => {
689 691 self.deactivate();
@@ -65,6 +65,7 @@ pub struct DirectBackend {
65 65 analysis_worker: Mutex<Option<WorkerHandle>>,
66 66 export_worker: Mutex<Option<ExportHandle>>,
67 67 cleanup_worker: Mutex<Option<CleanupHandle>>,
68 + loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>,
68 69 edit_worker: Mutex<Option<EditWorkerHandle>>,
69 70 forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>,
70 71 classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>,
@@ -91,6 +92,7 @@ impl DirectBackend {
91 92 analysis_worker: Mutex::new(None),
92 93 export_worker: Mutex::new(None),
93 94 cleanup_worker: Mutex::new(None),
95 + loose_files_worker: Mutex::new(None),
94 96 edit_worker: Mutex::new(None),
95 97 forge_worker: Mutex::new(None),
96 98 classifier_worker: Mutex::new(None),
@@ -118,6 +120,24 @@ impl DirectBackend {
118 120 &self.data_dir
119 121 }
120 122
123 + /// Lazily spawn the loose-files worker and send it a command. Keeps the
124 + /// integrity/relocate/purge filesystem work off the egui frame thread.
125 + fn loose_files_send(
126 + &self,
127 + cmd: crate::loose_files_worker::LooseFilesCommand,
128 + ) -> BackendResult<()> {
129 + let mut guard = self.loose_files_worker.lock();
130 + if guard.is_none() {
131 + let db_path = self.data_dir.join("audiofiles.db");
132 + let handle = crate::loose_files_worker::spawn_loose_files_worker(db_path).map_err(
133 + |e| BackendError::Other(format!("failed to spawn loose-files worker: {e}")),
134 + )?;
135 + *guard = Some(handle);
136 + }
137 + guard.as_ref().expect("loose-files worker present").send(cmd);
138 + Ok(())
139 + }
140 +
121 141 /// Lazily spawn the search worker on first use. It opens its own read-only
122 142 /// connection to the database file (WAL allows concurrent reads).
123 143 fn ensure_search_worker(&self) -> BackendResult<()> {
@@ -906,6 +926,20 @@ impl Backend for DirectBackend {
906 926 Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?)
907 927 }
908 928
929 + fn start_loose_files_check(&self) -> BackendResult<()> {
930 + self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::CheckIntegrity)
931 + }
932 +
933 + fn start_loose_files_relocate(&self, search_root: &std::path::Path) -> BackendResult<()> {
934 + self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::Relocate(
935 + search_root.to_path_buf(),
936 + ))
937 + }
938 +
939 + fn start_loose_files_purge(&self) -> BackendResult<()> {
940 + self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::Purge)
941 + }
942 +
909 943 // --- Export ---
910 944
911 945 fn collect_export_items(
@@ -1559,6 +1593,27 @@ impl Backend for DirectBackend {
1559 1593 }
1560 1594 }
1561 1595
1596 + // Poll loose-files worker
1597 + if let Some(ref worker) = *self.loose_files_worker.lock() {
1598 + use crate::loose_files_worker::LooseFilesEvent as Lfe;
1599 + while let Some(event) = worker.try_recv() {
1600 + events.push(match event {
1601 + Lfe::IntegrityResult { missing } => {
1602 + BackendEvent::LooseFilesIntegrity { missing }
1603 + }
1604 + Lfe::RelocateResult {
1605 + relocated,
1606 + still_missing,
1607 + } => BackendEvent::LooseFilesRelocated {
1608 + relocated,
1609 + still_missing,
1610 + },
1611 + Lfe::PurgeResult { purged } => BackendEvent::LooseFilesPurged { purged },
1612 + Lfe::Failed { message } => BackendEvent::LooseFilesFailed { message },
1613 + });
1614 + }
1615 + }
1616 +
1562 1617 // Poll edit worker
1563 1618 if let Some(ref worker) = *self.edit_worker.lock() {
1564 1619 while let Some(event) = worker.try_recv() {
@@ -110,6 +110,21 @@ pub enum BackendEvent {
110 110 errors: usize,
111 111 },
112 112
113 + // Loose-files maintenance events
114 + LooseFilesIntegrity {
115 + missing: usize,
116 + },
117 + LooseFilesRelocated {
118 + relocated: usize,
119 + still_missing: usize,
120 + },
121 + LooseFilesPurged {
122 + purged: usize,
123 + },
124 + LooseFilesFailed {
125 + message: String,
126 + },
127 +
113 128 // Edit events
114 129 EditStarted {
115 130 hash: String,
@@ -609,6 +624,20 @@ pub trait Backend: Send + Sync {
609 624 search_root: &std::path::Path,
610 625 ) -> BackendResult<(usize, usize)>;
611 626
627 + /// Start a background loose-files integrity check. The result arrives as a
628 + /// `BackendEvent::LooseFilesIntegrity` via [`Backend::poll_events`]. Off the
629 + /// GUI thread because it stats every loose source path.
630 + fn start_loose_files_check(&self) -> BackendResult<()>;
631 +
632 + /// Start a background relocate: walk `search_root`, hash-verify candidates,
633 + /// repoint missing samples. Result via `BackendEvent::LooseFilesRelocated`.
634 + /// Off the GUI thread because the walk + hashing is unbounded.
635 + fn start_loose_files_relocate(&self, search_root: &std::path::Path) -> BackendResult<()>;
636 +
637 + /// Start a background purge of loose-files samples whose source is missing.
638 + /// Result via `BackendEvent::LooseFilesPurged`.
639 + fn start_loose_files_purge(&self) -> BackendResult<()>;
640 +
612 641 // --- Export ---
613 642
614 643 /// Collect export items from a VFS subtree.
@@ -18,17 +18,21 @@ pub fn draw_browser(
18 18 let ctx = &ctx;
19 19 theme::apply_theme(ctx);
20 20
21 + // Drain background worker events every frame, regardless of which screen is
22 + // showing. A completion event (loose-files check, classifier, etc.) can land
23 + // while any mode is active; polling only in some modes left those events
24 + // unread and the UI un-repainted until the next input.
25 + if state.poll_workers() {
26 + ctx.request_repaint();
27 + }
28 + // Keep repainting while a background classifier job runs (no input would
29 + // otherwise repaint, so its completion event would sit unread).
30 + if state.classifier.busy.is_some() {
31 + ctx.request_repaint();
32 + }
33 +
21 34 match &state.import_mode {
22 35 ImportMode::None => {
23 - // Poll edit worker events during normal browsing
24 - if state.poll_workers() {
25 - ctx.request_repaint();
26 - }
27 - // Keep polling while a background classifier job runs (no input would
28 - // otherwise repaint, so its completion event would sit unread).
29 - if state.classifier.busy.is_some() {
30 - ctx.request_repaint();
31 - }
32 36 handle_keyboard(ctx, state);
33 37 draw_normal_browser(ui, state, sync_manager);
34 38 }
@@ -39,9 +43,6 @@ pub fn draw_browser(
39 43 | ImportMode::Analyzing { .. }
40 44 | ImportMode::Exporting { .. }
41 45 | ImportMode::Cleaning { .. } => {
42 - if state.poll_workers() {
43 - ctx.request_repaint();
44 - }
45 46 match &state.import_mode {
46 47 ImportMode::Importing { .. } => {
47 48 import_screens::draw_import_progress(ui, state);
@@ -8,6 +8,7 @@ pub mod error;
8 8 pub mod export;
9 9 pub mod import;
10 10 pub mod instrument;
11 + pub mod loose_files_worker;
11 12 pub mod preview;
12 13 pub mod state;
13 14 pub mod ui;
@@ -0,0 +1,174 @@
1 + //! Background worker for loose-files maintenance: integrity check, relocate, and
2 + //! purge. These walk the filesystem and hash candidate files, so running them on
3 + //! the egui thread froze the UI (ultra-fuzz UX finding). Mirrors `cleanup.rs`:
4 + //! a dedicated thread with its own Database, polled each frame via channels.
5 +
6 + use std::path::PathBuf;
7 + use std::sync::{mpsc, Mutex};
8 + use std::thread;
9 +
10 + use tracing::{error, instrument};
11 +
12 + use audiofiles_core::db::Database;
13 +
14 + /// Command from the GUI thread to the loose-files worker.
15 + pub enum LooseFilesCommand {
16 + /// Count loose-files samples whose source file is missing.
17 + CheckIntegrity,
18 + /// Walk `search_root` and repoint missing samples to matching files.
19 + Relocate(PathBuf),
20 + /// Remove loose-files samples whose source file is missing.
21 + Purge,
22 + /// Shut the worker down.
23 + Shutdown,
24 + }
25 +
26 + /// Event from the loose-files worker back to the GUI thread.
27 + pub enum LooseFilesEvent {
28 + /// Integrity check finished: `missing` source files absent.
29 + IntegrityResult { missing: usize },
30 + /// Relocate finished.
31 + RelocateResult { relocated: usize, still_missing: usize },
32 + /// Purge finished.
33 + PurgeResult { purged: usize },
34 + /// An operation failed.
35 + Failed { message: String },
36 + }
37 +
38 + /// Handle for communicating with the loose-files worker.
39 + ///
40 + /// The receiver is wrapped in a `Mutex` so `BrowserState` stays `Sync` (nih-plug);
41 + /// only the GUI thread calls `try_recv`, so contention is zero.
42 + pub struct LooseFilesHandle {
43 + cmd_tx: mpsc::Sender<LooseFilesCommand>,
44 + event_rx: Mutex<mpsc::Receiver<LooseFilesEvent>>,
45 + _thread: Option<thread::JoinHandle<()>>,
46 + }
47 +
48 + impl LooseFilesHandle {
49 + /// Poll for the next event without blocking.
50 + pub fn try_recv(&self) -> Option<LooseFilesEvent> {
51 + self.event_rx.lock().ok()?.try_recv().ok()
52 + }
53 +
54 + /// Send a command to the worker.
55 + pub fn send(&self, cmd: LooseFilesCommand) {
56 + let _ = self.cmd_tx.send(cmd);
57 + }
58 + }
59 +
60 + impl Drop for LooseFilesHandle {
61 + fn drop(&mut self) {
62 + let _ = self.cmd_tx.send(LooseFilesCommand::Shutdown);
63 + if let Some(handle) = self._thread.take() {
64 + let _ = handle.join();
65 + }
66 + }
67 + }
68 +
69 + /// Spawn the loose-files worker. It opens its own `Database` to avoid contending
70 + /// with the GUI connection.
71 + #[instrument(skip_all)]
72 + pub fn spawn_loose_files_worker(db_path: PathBuf) -> std::io::Result<LooseFilesHandle> {
73 + let (cmd_tx, cmd_rx) = mpsc::channel::<LooseFilesCommand>();
74 + let (event_tx, event_rx) = mpsc::channel::<LooseFilesEvent>();
75 +
76 + let thread = thread::Builder::new()
77 + .name("loose-files-worker".to_string())
78 + .spawn(move || worker_loop(&cmd_rx, &event_tx, &db_path))?;
79 +
80 + Ok(LooseFilesHandle {
81 + cmd_tx,
82 + event_rx: Mutex::new(event_rx),
83 + _thread: Some(thread),
84 + })
85 + }
86 +
87 + #[instrument(skip_all)]
88 + fn worker_loop(
89 + cmd_rx: &mpsc::Receiver<LooseFilesCommand>,
90 + event_tx: &mpsc::Sender<LooseFilesEvent>,
91 + db_path: &std::path::Path,
92 + ) {
93 + let db = match Database::open(db_path) {
94 + Ok(d) => d,
95 + Err(e) => {
96 + error!("Loose-files worker failed to open database: {e}");
97 + let _ = event_tx.send(LooseFilesEvent::Failed {
98 + message: "could not open the library database".to_string(),
99 + });
100 + return;
101 + }
102 + };
103 +
104 + while let Ok(cmd) = cmd_rx.recv() {
105 + let event = match cmd {
106 + LooseFilesCommand::Shutdown => break,
107 + LooseFilesCommand::CheckIntegrity => {
108 + match audiofiles_core::store::check_loose_files_integrity(&db) {
109 + Ok((_valid, missing)) => LooseFilesEvent::IntegrityResult { missing },
110 + Err(e) => LooseFilesEvent::Failed {
111 + message: e.to_string(),
112 + },
113 + }
114 + }
115 + LooseFilesCommand::Relocate(root) => {
116 + match audiofiles_core::store::relocate_missing_loose_files(&db, &root) {
117 + Ok((relocated, still_missing)) => LooseFilesEvent::RelocateResult {
118 + relocated,
119 + still_missing,
120 + },
121 + Err(e) => LooseFilesEvent::Failed {
122 + message: e.to_string(),
123 + },
124 + }
125 + }
126 + LooseFilesCommand::Purge => {
127 + match audiofiles_core::store::purge_missing_loose_files(&db) {
128 + Ok(purged) => LooseFilesEvent::PurgeResult { purged },
129 + Err(e) => LooseFilesEvent::Failed {
130 + message: e.to_string(),
131 + },
132 + }
133 + }
134 + };
135 + let _ = event_tx.send(event);
136 + }
137 + }
138 +
139 + #[cfg(test)]
140 + mod tests {
141 + use super::*;
142 +
143 + #[test]
144 + fn spawn_and_drop_does_not_hang() {
145 + let dir = tempfile::TempDir::new().unwrap();
146 + let db_path = dir.path().join("audiofiles.db");
147 + let _db = Database::open(&db_path).unwrap();
148 +
149 + let handle = spawn_loose_files_worker(db_path).unwrap();
150 + assert!(handle.try_recv().is_none());
151 + drop(handle);
152 + }
153 +
154 + #[test]
155 + fn integrity_check_reports_result() {
156 + let dir = tempfile::TempDir::new().unwrap();
157 + let db_path = dir.path().join("audiofiles.db");
158 + let _db = Database::open(&db_path).unwrap();
159 +
160 + let handle = spawn_loose_files_worker(db_path).unwrap();
161 + handle.send(LooseFilesCommand::CheckIntegrity);
162 +
163 + let mut got = false;
164 + for _ in 0..50 {
165 + if let Some(LooseFilesEvent::IntegrityResult { missing }) = handle.try_recv() {
166 + assert_eq!(missing, 0);
167 + got = true;
168 + break;
169 + }
170 + std::thread::sleep(std::time::Duration::from_millis(20));
171 + }
172 + assert!(got, "expected an IntegrityResult event");
173 + }
174 + }
@@ -702,6 +702,40 @@ impl BrowserState {
702 702 return true;
703 703 }
704 704
705 + // --- Loose-files maintenance events ---
706 + BackendEvent::LooseFilesIntegrity { missing } => {
707 + self.loose_files_missing_count = missing;
708 + if missing > 0 {
709 + self.show_loose_files_warning = true;
710 + }
711 + }
712 + BackendEvent::LooseFilesRelocated {
713 + relocated,
714 + still_missing,
715 + } => {
716 + self.loose_files_missing_count = still_missing;
717 + self.status = if relocated == 0 {
718 + "No matching files found in that folder.".to_string()
719 + } else if still_missing == 0 {
720 + format!("Relocated {relocated} samples. All missing files found.")
721 + } else {
722 + format!("Relocated {relocated} samples. {still_missing} still missing.")
723 + };
724 + if still_missing == 0 {
725 + self.show_loose_files_warning = false;
726 + }
727 + self.refresh_contents();
728 + }
729 + BackendEvent::LooseFilesPurged { purged } => {
730 + self.status = format!("Purged {purged} missing samples");
731 + self.loose_files_missing_count = 0;
732 + self.show_loose_files_warning = false;
733 + self.refresh_contents();
734 + }
735 + BackendEvent::LooseFilesFailed { message } => {
736 + self.status = format!("Loose-files operation failed: {message}");
737 + }
738 +
705 739 // --- Edit events ---
706 740 BackendEvent::EditStarted { hash: _ } => {
707 741 self.edit.in_progress = true;
@@ -774,11 +808,11 @@ impl BrowserState {
774 808 // --- Search events (similarity / near-duplicate, off-thread) ---
775 809 BackendEvent::SimilarResults { source, hashes } => {
776 810 let refs: Vec<&str> = hashes.iter().map(String::as_str).collect();
777 - self.apply_similarity_results(&source, &refs, "similar samples");
811 + self.apply_similarity_results(&source, &refs, "similar samples", false);
778 812 }
779 813 BackendEvent::NearDuplicateResults { source, hashes } => {
780 814 let refs: Vec<&str> = hashes.iter().map(String::as_str).collect();
781 - self.apply_similarity_results(&source, &refs, "near-duplicates");
815 + self.apply_similarity_results(&source, &refs, "near-duplicates", true);
782 816 }
783 817 BackendEvent::SearchError { error } => {
784 818 self.status = format!("Search failed: {error}");
@@ -40,32 +40,19 @@ impl BrowserState {
40 40 self.loose_files_missing_count = 0;
41 41 return;
42 42 }
43 - match self.backend.check_vault_integrity() {
44 - Ok((_valid, missing)) => {
45 - self.loose_files_missing_count = missing;
46 - if missing > 0 {
47 - self.show_loose_files_warning = true;
48 - }
49 - }
50 - Err(e) => {
51 - warn!("Loose-files integrity check failed: {e}");
52 - }
43 + // Runs off the GUI thread: it stats every loose source path. The result
44 + // arrives as BackendEvent::LooseFilesIntegrity (see poll_workers).
45 + if let Err(e) = self.backend.start_loose_files_check() {
46 + warn!("Loose-files integrity check failed to start: {e}");
53 47 }
54 48 }
55 49
56 50 /// Purge all loose-files mode samples whose source files are missing.
57 - /// Refreshes the VFS listing afterward.
51 + /// Runs in the background; the VFS listing refreshes when the result lands.
58 52 pub fn purge_missing_loose_files(&mut self) {
59 - match self.backend.purge_missing_loose_files() {
60 - Ok(purged) => {
61 - self.status = format!("Purged {purged} missing samples");
62 - self.loose_files_missing_count = 0;
63 - self.show_loose_files_warning = false;
64 - self.refresh_contents();
65 - }
66 - Err(e) => {
67 - self.status = format!("Purge failed: {e}");
68 - }
53 + match self.backend.start_loose_files_purge() {
54 + Ok(()) => self.status = "Purging missing samples\u{2026}".to_string(),
55 + Err(e) => self.status = format!("Purge failed: {e}"),
69 56 }
70 57 }
71 58
@@ -79,26 +66,12 @@ impl BrowserState {
79 66 /// `loose_files_missing_count` so the user can run Locate again against a
80 67 /// different directory if some samples are still missing.
81 68 pub fn locate_missing_loose_files(&mut self, search_root: std::path::PathBuf) {
82 - match self.backend.relocate_missing_loose_files(&search_root) {
83 - Ok((relocated, still_missing)) => {
84 - self.loose_files_missing_count = still_missing;
85 - self.status = if relocated == 0 {
86 - "No matching files found in that folder.".to_string()
87 - } else if still_missing == 0 {
88 - format!("Relocated {relocated} samples. All missing files found.")
89 - } else {
90 - format!(
91 - "Relocated {relocated} samples. {still_missing} still missing.",
92 - )
93 - };
94 - if still_missing == 0 {
95 - self.show_loose_files_warning = false;
96 - }
97 - self.refresh_contents();
98 - }
99 - Err(e) => {
100 - self.status = format!("Could not locate sample on disk \u{2014} {e}");
101 - }
69 + // Runs off the GUI thread: it walks `search_root` and SHA-256-verifies
70 + // candidates, which is unbounded. The result arrives as
71 + // BackendEvent::LooseFilesRelocated (see poll_workers).
72 + match self.backend.start_loose_files_relocate(&search_root) {
73 + Ok(()) => self.status = "Searching that folder for missing samples\u{2026}".to_string(),
74 + Err(e) => self.status = format!("Could not start locate \u{2014} {e}"),
102 75 }
103 76 }
104 77
@@ -254,6 +227,7 @@ impl BrowserState {
254 227 pub fn find_similar(&mut self, hash: &str) {
255 228 match self.backend.start_find_similar(hash, 50) {
256 229 Ok(()) => {
230 + self.latest_similarity_request = Some((hash.to_string(), false));
257 231 self.status = "Searching for similar samples...".to_string();
258 232 }
259 233 Err(e) => {
@@ -267,6 +241,7 @@ impl BrowserState {
267 241 pub fn find_near_duplicates(&mut self, hash: &str) {
268 242 match self.backend.start_find_near_duplicates(hash, 50) {
269 243 Ok(()) => {
244 + self.latest_similarity_request = Some((hash.to_string(), true));
270 245 self.status = "Searching for near-duplicates...".to_string();
271 246 }
272 247 Err(e) => {
@@ -278,7 +253,20 @@ impl BrowserState {
278 253 /// Apply search results to the file list (called from the poll loop).
279 254 /// `noun` is the human label for the status line ("similar samples" /
280 255 /// "near-duplicates"). `hashes` are the result hashes in rank order.
281 - pub fn apply_similarity_results(&mut self, source_hash: &str, hashes: &[&str], noun: &str) {
256 + pub fn apply_similarity_results(
257 + &mut self,
258 + source_hash: &str,
259 + hashes: &[&str],
260 + noun: &str,
261 + is_near_dup: bool,
262 + ) {
263 + // Drop stale results: if the user fired another search after this one,
264 + // the request won't match and a slow query can't clobber the view.
265 + if self.latest_similarity_request.as_ref()
266 + != Some(&(source_hash.to_string(), is_near_dup))
267 + {
268 + return;
269 + }
282 270 let Some(vfs_id) = self.current_vfs_id() else { return };
283 271 let nodes = self
284 272 .backend
@@ -296,6 +284,7 @@ impl BrowserState {
296 284 pub fn clear_similarity_search(&mut self) {
297 285 self.similarity_search_hash = None;
298 286 self.similarity_source_name = None;
287 + self.latest_similarity_request = None;
299 288 self.refresh_contents();
300 289 }
301 290
@@ -139,6 +139,10 @@ pub struct BrowserState {
139 139
140 140 // Similarity search
141 141 pub similarity_search_hash: Option<String>,
142 + /// The most recent similarity request as `(source_hash, is_near_dup)`. Async
143 + /// results that don't match it are stale (the user fired another search
144 + /// first) and are dropped, so a slow query can't replace the current view.
145 + pub latest_similarity_request: Option<(String, bool)>,
142 146 /// Display name of the source sample for the active similarity / duplicate
143 147 /// search. Cached so the breadcrumb can render "Similar to: <name>" without
144 148 /// a backend lookup on every frame.
@@ -489,6 +493,7 @@ impl BrowserState {
489 493 collection_filter_name_input: String::new(),
490 494 filter_tag_input: String::new(),
491 495 similarity_search_hash: None,
496 + latest_similarity_request: None,
492 497 similarity_source_name: None,
493 498 all_tags: Arc::new(all_tags),
494 499 tag_search: String::new(),
@@ -269,8 +269,6 @@ pub struct SettingsUiState {
269 269 /// Unix timestamp (seconds) of the last successful storage scan, used to
270 270 /// render a "last scanned N minutes ago" label so stale numbers are visible.
271 271 pub storage_cache_at: Option<i64>,
272 - /// Whether a storage scan is in progress.
273 - pub storage_scanning: bool,
274 272 /// Masked license key for display.
275 273 pub license_key_masked: Option<String>,
276 274 /// Machine ID for display.