Skip to main content

max / audiofiles

Decompose BrowserState god-struct into cohesive sub-structs Reduce BrowserState from 112 top-level fields to 54 by extracting nine sub-structs, following the existing edit/forge/sync/settings grouping pattern: nav (navigation + selection + sort + contents), search, import_wf (19 fields), collections_ui, overlay, vfs_modal, onboarding, mirror, loose_files. Retires the Architecture/Size cold spot the audit has flagged since the field wall grew. Mechanical, zero behavior change: access sites rewritten to self.<group>.<field> across the browser and app crates; constructor carries all computed startup values into the sub-struct literals; ImportMode/SortColumn/SortDirection gain derived Default. The full nav cluster grouped with no borrow-checker friction. Also carries two Run 19 test-robustness fixes that live in the rewritten files: - H1 flaky playback tests: convert the await_* helpers from iteration-count loops to Instant-based wall-clock deadlines (state/tests.rs). - H7 non-total sort: order optional BPM/duration via cmp_opt_f64 (total_cmp, None-first) instead of partial_cmp().unwrap_or(Equal) (state/navigation.rs). 1113 tests pass, workspace clippy-clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 15:14 UTC
Signed with PGP, not checked
Commit: 3f0916fb9bf20dc2b2b3d077dd1f79bd2ec4f75d
Parent: 3e902f1
26 files changed, +1012 insertions, -961 deletions
@@ -823,7 +823,7 @@
823 823 if path.is_dir() {
824 824 let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
825 825 vfs_id,
826 - parent_id: browser.current_dir,
826 + parent_id: browser.nav.current_dir,
827 827 };
828 828 browser.start_folder_import(path, strategy);
829 829 } else {
@@ -835,8 +835,8 @@
835 835
836 836 // Toolbar Help menu → About entry. Browser sets `about_requested`;
837 837 // we consume it and flip our own modal flag.
838 - if browser.about_requested {
839 - browser.about_requested = false;
838 + if browser.overlay.about_requested {
839 + browser.overlay.about_requested = false;
840 840 self.show_about = true;
841 841 }
842 842
@@ -41,7 +41,7 @@
41 41 ctx.request_repaint();
42 42 }
43 43
44 - match &state.import_mode {
44 + match &state.import_wf.import_mode {
45 45 ImportMode::None => {
46 46 handle_keyboard(ctx, state);
47 47 draw_normal_browser(ui, state, sync_manager);
@@ -53,7 +53,7 @@
53 53 | ImportMode::Analyzing { .. }
54 54 | ImportMode::Exporting { .. }
55 55 | ImportMode::Cleaning { .. } => {
56 - match &state.import_mode {
56 + match &state.import_wf.import_mode {
57 57 ImportMode::Importing { .. } => {
58 58 import_screens::draw_import_progress(ui, state);
59 59 }
@@ -111,45 +111,45 @@
111 111 // Scrim behind genuine modals (not the floating tool windows). Painted once
112 112 // before any modal so it sits below the topmost modal but blocks pointer
113 113 // input to the live UI underneath (P2).
114 - let modal_active = state.pending_confirm.is_some()
114 + let modal_active = state.overlay.pending_confirm.is_some()
115 115 || state.bulk_modal.is_some()
116 - || state.show_help
117 - || state.show_vfs_create
118 - || state.vfs_rename_target.is_some()
119 - || state.show_dir_create
120 - || state.dir_rename_target.is_some()
121 - || state.show_loose_files_warning
122 - || state.pending_import_preflight.is_some();
116 + || state.overlay.show_help
117 + || state.vfs_modal.show_vfs_create
118 + || state.vfs_modal.vfs_rename_target.is_some()
119 + || state.vfs_modal.show_dir_create
120 + || state.vfs_modal.dir_rename_target.is_some()
121 + || state.loose_files.show_loose_files_warning
122 + || state.import_wf.pending_import_preflight.is_some();
123 123 if modal_active {
124 124 crate::ui::widgets::modal_scrim(ctx);
125 125 }
126 126
127 127 // Overlays drawn on top of any screen
128 - if state.pending_confirm.is_some() {
128 + if state.overlay.pending_confirm.is_some() {
129 129 overlays::draw_confirm_dialog(ctx, state);
130 130 }
131 131 if state.bulk_modal.is_some() {
132 132 overlays::draw_bulk_modal(ctx, state);
133 133 }
134 - if state.show_help {
134 + if state.overlay.show_help {
135 135 overlays::draw_help_overlay(ctx, state);
136 136 }
137 - if state.show_vfs_create {
137 + if state.vfs_modal.show_vfs_create {
138 138 overlays::draw_vfs_create_modal(ctx, state);
139 139 }
140 - if state.vfs_rename_target.is_some() {
140 + if state.vfs_modal.vfs_rename_target.is_some() {
141 141 overlays::draw_vfs_rename_modal(ctx, state);
142 142 }
143 - if state.show_dir_create {
143 + if state.vfs_modal.show_dir_create {
144 144 overlays::draw_dir_create_modal(ctx, state);
145 145 }
146 - if state.dir_rename_target.is_some() {
146 + if state.vfs_modal.dir_rename_target.is_some() {
147 147 overlays::draw_dir_rename_modal(ctx, state);
148 148 }
149 - if state.show_loose_files_warning {
149 + if state.loose_files.show_loose_files_warning {
150 150 overlays::draw_loose_files_warning(ctx, state);
151 151 }
152 - if state.pending_import_preflight.is_some() {
152 + if state.import_wf.pending_import_preflight.is_some() {
153 153 overlays::draw_import_preflight(ctx, state);
154 154 }
155 155
@@ -205,7 +205,7 @@
205 205 }
206 206
207 207 // Left sidebar (or filter panel)
208 - if state.filter_panel_open {
208 + if state.search.filter_panel_open {
209 209 egui::Panel::left("filter_panel")
210 210 .default_size(200.0)
211 211 .size_range(160.0..=300.0)
@@ -250,9 +250,9 @@
250 250 // Still handle Escape to clear search
251 251 ctx.input(|input| {
252 252 if input.key_pressed(egui::Key::Escape)
253 - && !state.search_query.is_empty()
253 + && !state.search.search_query.is_empty()
254 254 {
255 - state.search_query.clear();
255 + state.search.search_query.clear();
256 256 state.apply_search();
257 257 }
258 258 });
@@ -272,14 +272,14 @@
272 272 state.sync.show_panel = false;
273 273 } else if state.bulk_modal.is_some() {
274 274 state.close_bulk_modal();
275 - } else if state.pending_import_preflight.is_some() {
275 + } else if state.import_wf.pending_import_preflight.is_some() {
276 276 state.cancel_import_preflight();
277 - } else if state.pending_confirm.is_some() {
277 + } else if state.overlay.pending_confirm.is_some() {
278 278 state.dismiss_confirm();
279 - } else if state.show_help {
280 - state.show_help = false;
279 + } else if state.overlay.show_help {
280 + state.overlay.show_help = false;
281 281 } else if matches!(
282 - state.import_mode,
282 + state.import_wf.import_mode,
283 283 ImportMode::ConfigureImport { .. }
284 284 | ImportMode::TagFolders { .. }
285 285 | ImportMode::ConfigureAnalysis { .. }
@@ -292,23 +292,23 @@
292 292 // Active modes (Importing/Analyzing/Cleaning/Exporting) require the
293 293 // explicit Cancel button to avoid losing in-progress work.
294 294 state.cancel_import();
295 - } else if matches!(state.import_mode, ImportMode::OperationCancelled { .. }) {
295 + } else if matches!(state.import_wf.import_mode, ImportMode::OperationCancelled { .. }) {
296 296 // Cancel-acknowledgement (C-3) dismisses on Escape just like the
297 297 // Done button — the file work has already been cancelled.
298 - state.import_mode = ImportMode::None;
299 - } else if !state.search_query.is_empty() {
300 - state.search_query.clear();
298 + state.import_wf.import_mode = ImportMode::None;
299 + } else if !state.search.search_query.is_empty() {
300 + state.search.search_query.clear();
301 301 state.apply_search();
302 302 }
303 303 return;
304 304 }
305 305
306 306 if input.key_pressed(egui::Key::F1) {
307 - state.show_help = !state.show_help;
307 + state.overlay.show_help = !state.overlay.show_help;
308 308 }
309 309
310 310 if input.key_pressed(egui::Key::F2)
311 - && state.selection.count() > 1
311 + && state.nav.selection.count() > 1
312 312 {
313 313 state.open_bulk_rename_modal();
314 314 if state.bulk_modal.is_some() {
@@ -324,8 +324,8 @@
324 324 // and must never be part of a bulk operation).
325 325 if input.modifiers.command && input.key_pressed(egui::Key::A) && !input.modifiers.shift {
326 326 let len = state.visible_len();
327 - let start = if state.current_dir.is_some() { 1 } else { 0 };
328 - state.selection.select_all_from(start, len);
327 + let start = if state.nav.current_dir.is_some() { 1 } else { 0 };
328 + state.nav.selection.select_all_from(start, len);
329 329 return;
330 330 }
331 331
@@ -352,7 +352,7 @@
352 352
353 353 // Cmd+T: bulk tag
354 354 if input.modifiers.command && input.key_pressed(egui::Key::T) {
355 - if state.selection.count() > 1 {
355 + if state.nav.selection.count() > 1 {
356 356 state.open_bulk_tag_modal();
357 357 }
358 358 return;
@@ -362,8 +362,8 @@
362 362
363 363 if input.key_pressed(egui::Key::ArrowDown) || input.key_pressed(egui::Key::J) {
364 364 if shift {
365 - state.selection.extend_down(state.visible_len());
366 - state.scroll_to_row = Some(state.selection.focus);
365 + state.nav.selection.extend_down(state.visible_len());
366 + state.nav.scroll_to_row = Some(state.nav.selection.focus);
367 367 } else {
368 368 state.select_next();
369 369 state.autoplay_current();
@@ -371,8 +371,8 @@
371 371 }
372 372 if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) {
373 373 if shift {
374 - state.selection.extend_up();
375 - state.scroll_to_row = Some(state.selection.focus);
374 + state.nav.selection.extend_up();
375 + state.nav.scroll_to_row = Some(state.nav.selection.focus);
376 376 } else {
377 377 state.select_prev();
378 378 state.autoplay_current();
@@ -389,7 +389,7 @@
389 389 }
390 390 }
391 391 }
392 - } else if state.current_dir.is_some() && state.selection.focus == 0 {
392 + } else if state.nav.current_dir.is_some() && state.nav.selection.focus == 0 {
393 393 state.go_up();
394 394 }
395 395 }
@@ -398,7 +398,7 @@
398 398 // first press exits the mode; a follow-up press then falls through
399 399 // to "go up one folder." Matches the "Esc closes the dialog, then
400 400 // Esc closes the parent" muscle memory.
401 - if state.similarity_search_hash.is_some() {
401 + if state.search.similarity_search_hash.is_some() {
402 402 state.clear_similarity_search();
403 403 } else {
404 404 state.go_up();
@@ -463,7 +463,7 @@
463 463 }
464 464 // Cmd+Shift+M: bulk move (Cmd+M alone conflicts with macOS minimize)
465 465 if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::M)
466 - && state.selection.count() > 1 {
466 + && state.nav.selection.count() > 1 {
467 467 state.open_bulk_move_modal();
468 468 }
469 469 });
@@ -5,10 +5,10 @@
5 5 impl BrowserState {
6 6 /// Show the delete confirmation dialog for the current selection.
7 7 pub fn confirm_delete_selected(&mut self) {
8 - if self.selection.count() > 1 {
8 + if self.nav.selection.count() > 1 {
9 9 self.confirm_delete_selection();
10 10 } else if let Some(node) = self.selected_node() {
11 - self.pending_confirm = Some(ConfirmAction::DeleteNode {
11 + self.overlay.pending_confirm = Some(ConfirmAction::DeleteNode {
12 12 node_id: node.node.id,
13 13 node_name: node.node.name.clone(),
14 14 });
@@ -18,12 +18,12 @@
18 18 /// Execute the pending confirmed action (delete node, delete VFS, or bulk delete).
19 19 pub fn execute_confirmed_action(&mut self) {
20 20 // Handle bulk delete separately since it needs &mut self
21 - if matches!(&self.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })) {
21 + if matches!(&self.overlay.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })) {
22 22 self.execute_bulk_delete();
23 23 return;
24 24 }
25 25
26 - let action = self.pending_confirm.take();
26 + let action = self.overlay.pending_confirm.take();
27 27 match action {
28 28 Some(ConfirmAction::DeleteNode { node_id, node_name }) => {
29 29 match self.backend.delete_node(node_id) {
@@ -39,7 +39,7 @@
39 39 Ok(()) => {
40 40 // Start background cleanup for orphaned samples
41 41 if self.backend.start_cleanup().is_ok() {
42 - self.import_mode = ImportMode::Cleaning {
42 + self.import_wf.import_mode = ImportMode::Cleaning {
43 43 completed: 0,
44 44 total: 0,
45 45 current_name: String::new(),
@@ -75,7 +75,7 @@
75 75 match self.backend.remove_tag_globally(&tag) {
76 76 Ok(count) => {
77 77 self.refresh_all_tags();
78 - self.search_filter.required_tags.retain(|t| t != &tag);
78 + self.search.search_filter.required_tags.retain(|t| t != &tag);
79 79 self.apply_search();
80 80 self.status = format!("Removed tag \"{tag}\" from {count} sample{}",
81 81 if count == 1 { "" } else { "s" });
@@ -87,7 +87,7 @@
87 87 match self.backend.delete_collection(coll_id) {
88 88 Ok(()) => {
89 89 // Deactivate if the deleted collection was the active one.
90 - if self.active_collection == Some(coll_id) {
90 + if self.collections_ui.active_collection == Some(coll_id) {
91 91 self.deactivate_collection();
92 92 }
93 93 self.refresh_collections();
@@ -123,52 +123,52 @@
123 123
124 124 /// Dismiss the confirmation dialog without taking action.
125 125 pub fn dismiss_confirm(&mut self) {
126 - self.pending_confirm = None;
126 + self.overlay.pending_confirm = None;
127 127 }
128 128
129 129 // --- Bulk operations ---
130 130
131 131 /// All selected nodes, excluding ".." parent entry.
132 132 pub fn selected_nodes(&self) -> Vec<VfsNodeWithAnalysis> {
133 - let offset = if self.current_dir.is_some() { 1 } else { 0 };
134 - self.selection
133 + let offset = if self.nav.current_dir.is_some() { 1 } else { 0 };
134 + self.nav.selection
135 135 .selected
136 136 .iter()
137 137 .filter_map(|&idx| {
138 138 if offset > 0 && idx == 0 {
139 139 return None; // ".." entry
140 140 }
141 - self.contents.get(idx - offset).cloned()
141 + self.nav.contents.get(idx - offset).cloned()
142 142 })
143 143 .collect()
144 144 }
145 145
146 146 /// Node IDs of all selected items, excluding ".." parent entry.
147 147 pub fn selected_node_ids(&self) -> Vec<NodeId> {
148 - let offset = if self.current_dir.is_some() { 1 } else { 0 };
149 - self.selection
148 + let offset = if self.nav.current_dir.is_some() { 1 } else { 0 };
149 + self.nav.selection
150 150 .selected
151 151 .iter()
152 152 .filter_map(|&idx| {
153 153 if offset > 0 && idx == 0 {
154 154 return None;
155 155 }
156 - self.contents.get(idx - offset).map(|n| n.node.id)
156 + self.nav.contents.get(idx - offset).map(|n| n.node.id)
157 157 })
158 158 .collect()
159 159 }
160 160
161 161 /// Hashes of all selected samples.
162 162 pub fn selected_sample_hashes(&self) -> Vec<String> {
163 - let offset = if self.current_dir.is_some() { 1 } else { 0 };
164 - self.selection
163 + let offset = if self.nav.current_dir.is_some() { 1 } else { 0 };
164 + self.nav.selection
165 165 .selected
166 166 .iter()
167 167 .filter_map(|&idx| {
168 168 if offset > 0 && idx == 0 {
169 169 return None;
170 170 }
171 - self.contents
171 + self.nav.contents
172 172 .get(idx - offset)
173 173 .and_then(|n| n.node.sample_hash.as_ref().map(|h| h.to_string()))
174 174 })
@@ -530,11 +530,11 @@
530 530
531 531 /// Delete all confirmed nodes, snapshotting the full subtree and tags for undo.
532 532 pub fn execute_bulk_delete(&mut self) {
533 - let node_ids = match &self.pending_confirm {
533 + let node_ids = match &self.overlay.pending_confirm {
534 534 Some(ConfirmAction::DeleteMultiple { node_ids, .. }) => node_ids.clone(),
535 535 _ => return,
536 536 };
537 - self.pending_confirm = None;
537 + self.overlay.pending_confirm = None;
538 538
539 539 // Snapshot for undo
540 540 let mut all_nodes = Vec::new();
@@ -582,14 +582,14 @@
582 582 return;
583 583 }
584 584 if nodes.len() == 1 {
585 - self.pending_confirm = Some(ConfirmAction::DeleteNode {
585 + self.overlay.pending_confirm = Some(ConfirmAction::DeleteNode {
586 586 node_id: nodes[0].node.id,
587 587 node_name: nodes[0].node.name.clone(),
588 588 });
589 589 } else {
590 590 let node_ids: Vec<NodeId> = nodes.iter().map(|n| n.node.id).collect();
591 591 let count = node_ids.len();
592 - self.pending_confirm = Some(ConfirmAction::DeleteMultiple { node_ids, count });
592 + self.overlay.pending_confirm = Some(ConfirmAction::DeleteMultiple { node_ids, count });
593 593 }
594 594 }
595 595
@@ -112,7 +112,7 @@
112 112 let ext = self.forge.ext.clone();
113 113 let name = self.forge.name.clone();
114 114 let method = self.forge_chop_method();
115 - let parent_id = self.current_dir;
115 + let parent_id = self.nav.current_dir;
116 116
117 117 self.forge.busy = true;
118 118 self.status = "Chopping...".to_string();
@@ -153,7 +153,7 @@
153 153 };
154 154 let ext = self.forge.ext.clone();
155 155 let name = self.forge.name.clone();
156 - let parent_id = self.current_dir;
156 + let parent_id = self.nav.current_dir;
157 157
158 158 self.forge.busy = true;
159 159 // Remember the device so the completion handler can name it in the
@@ -61,10 +61,10 @@
61 61 /// to the flushed hashes (also one transaction). Rules run after the save so
62 62 /// they evaluate against the persisted analysis. No-op when the buffer empty.
63 63 fn flush_analysis_saves(&mut self) {
64 - if self.analysis_save_buffer.is_empty() {
64 + if self.import_wf.analysis_save_buffer.is_empty() {
65 65 return;
66 66 }
67 - let results = std::mem::take(&mut self.analysis_save_buffer);
67 + let results = std::mem::take(&mut self.import_wf.analysis_save_buffer);
68 68 let hashes: Vec<String> = results.iter().map(|r| r.hash.clone()).collect();
69 69 let n = results.len();
70 70 if let Err(e) = self.backend.save_analysis_batch(&results) {
@@ -88,7 +88,7 @@
88 88 self.status = "No VFS available".to_string();
89 89 return;
90 90 };
91 - let parent_id = self.current_dir;
91 + let parent_id = self.nav.current_dir;
92 92 let mut hashes = Vec::new();
93 93
94 94 if path.is_file() {
@@ -227,7 +227,7 @@
227 227 /// review/progress screens, and yields to user-initiated analysis (which cancels and
228 228 /// replaces the worker). Resumable: leftover samples are picked up on the next call.
229 229 pub fn start_backfill(&mut self) {
230 - if self.backfill_in_progress || !matches!(self.import_mode, ImportMode::None) {
230 + if self.import_wf.backfill_in_progress || !matches!(self.import_wf.import_mode, ImportMode::None) {
231 231 return;
232 232 }
233 233 let needed = self.backend.samples_needing_features().unwrap_or_default();
@@ -235,14 +235,14 @@
235 235 return;
236 236 }
237 237 let count = needed.len();
238 - self.backfill_in_progress = true;
238 + self.import_wf.backfill_in_progress = true;
239 239 self.status = format!("Backfilling features for {count} samples…");
240 240 if self
241 241 .backend
242 242 .start_analysis(needed, AnalysisConfig::default())
243 243 .is_err()
244 244 {
245 - self.backfill_in_progress = false;
245 + self.import_wf.backfill_in_progress = false;
246 246 self.status = "Feature backfill could not start".to_string();
247 247 }
248 248 }
@@ -252,7 +252,7 @@
252 252 if sample_hashes.is_empty() {
253 253 return;
254 254 }
255 - self.import_mode = ImportMode::ConfigureAnalysis {
255 + self.import_wf.import_mode = ImportMode::ConfigureAnalysis {
256 256 sample_hashes,
257 257 config: AnalysisConfig::default(),
258 258 };
@@ -261,15 +261,15 @@
261 261 /// Spawn the background analysis worker and start processing samples.
262 262 pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) {
263 263 // Stash parameters so the retry button can restart analysis.
264 - self.last_analysis_hashes = sample_hashes.clone();
265 - self.last_analysis_config = Some(config.clone());
264 + self.import_wf.last_analysis_hashes = sample_hashes.clone();
265 + self.import_wf.last_analysis_config = Some(config.clone());
266 266
267 267 let total = sample_hashes.len();
268 268
269 - self.pending_review_items.clear();
270 - self.analysis_errors.clear();
271 - self.operation_progress = Some(crate::state::OperationProgress::new());
272 - self.import_mode = ImportMode::Analyzing {
269 + self.import_wf.pending_review_items.clear();
270 + self.import_wf.analysis_errors.clear();
271 + self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
272 + self.import_wf.import_mode = ImportMode::Analyzing {
273 273 completed: 0,
274 274 total,
275 275 current_name: String::new(),
@@ -284,7 +284,7 @@
284 284 /// Falls through to `None` only when there's no meaningful progress to
285 285 /// acknowledge (cancel before any work happened).
286 286 pub fn cancel_analysis(&mut self) {
287 - let progress = match &self.import_mode {
287 + let progress = match &self.import_wf.import_mode {
288 288 ImportMode::Analyzing { completed, total, .. } => Some((*completed, *total)),
289 289 _ => None,
290 290 };
@@ -294,11 +294,11 @@
294 294 // import isn't silently treated as quick/backfill (they only reset on the
295 295 // happy path otherwise — the flag-leak finding).
296 296 self.flush_analysis_saves();
297 - self.quick_import = false;
298 - self.backfill_in_progress = false;
299 - self.pending_review_items.clear();
297 + self.import_wf.quick_import = false;
298 + self.import_wf.backfill_in_progress = false;
299 + self.import_wf.pending_review_items.clear();
300 300 self.status = "Analysis cancelled".to_string();
301 - self.import_mode = match progress {
301 + self.import_wf.import_mode = match progress {
302 302 Some((completed, total)) if total > 0 => ImportMode::OperationCancelled {
303 303 kind: crate::state::CancelKind::Analysis,
304 304 completed,
@@ -322,11 +322,11 @@
322 322 pub fn quick_import_folder(&mut self, source: PathBuf) {
323 323 let (file_count, total_bytes) = walk_folder_stats(&source);
324 324 // M-9: skip preflight entirely when the user has opted out persistently.
325 - let should_preflight = !self.import_preflight_disabled
325 + let should_preflight = !self.import_wf.import_preflight_disabled
326 326 && (file_count >= QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD
327 327 || total_bytes >= QUICK_IMPORT_PREFLIGHT_BYTE_THRESHOLD);
328 328 if should_preflight {
329 - self.pending_import_preflight = Some(ImportPreflight {
329 + self.import_wf.pending_import_preflight = Some(ImportPreflight {
330 330 source,
331 331 file_count,
332 332 total_bytes,
@@ -344,21 +344,21 @@
344 344 .and_then(|n| n.to_str())
345 345 .unwrap_or("folder")
346 346 .to_string();
347 - self.quick_import = true;
347 + self.import_wf.quick_import = true;
348 348 let strategy = ImportStrategy::NewVfs { vfs_name };
349 349 self.start_folder_import(source, strategy);
350 350 }
351 351
352 352 /// Accept the pending preflight and start the import.
353 353 pub fn accept_import_preflight(&mut self) {
354 - if let Some(p) = self.pending_import_preflight.take() {
354 + if let Some(p) = self.import_wf.pending_import_preflight.take() {
355 355 self.start_quick_import_now(p.source);
356 356 }
357 357 }
358 358
359 359 /// Discard the pending preflight without starting an import.
360 360 pub fn cancel_import_preflight(&mut self) {
361 - self.pending_import_preflight = None;
361 + self.import_wf.pending_import_preflight = None;
362 362 }
363 363
364 364 /// Open the import configuration modal for a dropped or selected folder.
@@ -376,7 +376,7 @@
376 376 // Dry-run: count audio files in the source folder
377 377 let audio_file_count = count_audio_files(&source);
378 378
379 - self.import_mode = ImportMode::ConfigureImport {
379 + self.import_wf.import_mode = ImportMode::ConfigureImport {
380 380 source,
381 381 source_name: source_name.clone(),
382 382 strategy: ImportStrategy::NewVfs {
@@ -405,7 +405,7 @@
405 405 source_name,
406 406 audio_file_count,
407 407 ..
408 - } = &mut self.import_mode
408 + } = &mut self.import_wf.import_mode
409 409 {
410 410 *source = new_source;
411 411 *source_name = new_name;
@@ -416,7 +416,7 @@
416 416 /// Spawn the background import worker to walk and import a folder.
417 417 pub fn start_folder_import(&mut self, source: PathBuf, strategy: ImportStrategy) {
418 418 // Stash source path so the retry button can re-open the config screen.
419 - self.last_import_source = Some(source.clone());
419 + self.import_wf.last_import_source = Some(source.clone());
420 420
421 421 let strategy_desc = match strategy {
422 422 ImportStrategy::Flat { vfs_id, parent_id } => {
@@ -430,11 +430,11 @@
430 430
431 431 super::log_backend_err("start_import", self.backend.start_import(&source, strategy_desc));
432 432
433 - self.import_file_errors.clear();
434 - self.analysis_errors.clear();
435 - self.operation_progress = Some(crate::state::OperationProgress::new());
433 + self.import_wf.import_file_errors.clear();
434 + self.import_wf.analysis_errors.clear();
435 + self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
436 436 let loose_files = self.settings.is_loose_files;
437 - self.import_mode = ImportMode::Importing {
437 + self.import_wf.import_mode = ImportMode::Importing {
438 438 total: 0,
439 439 completed: 0,
440 440 current_name: String::new(),
@@ -457,8 +457,8 @@
457 457 pub fn any_worker_busy(&self) -> bool {
458 458 self.classifier.busy.is_some()
459 459 || self.forge.busy
460 - || self.loose_files_busy
461 - || self.latest_similarity_request.is_some()
460 + || self.loose_files.loose_files_busy
461 + || self.search.latest_similarity_request.is_some()
462 462 }
463 463
464 464 /// Poll all backend workers for events. Returns `true` if any events were processed.
@@ -488,7 +488,7 @@
488 488 walking_count,
489 489 total_bytes: bytes_slot,
490 490 ..
491 - } = &mut self.import_mode
491 + } = &mut self.import_wf.import_mode
492 492 {
493 493 *walking_count = count;
494 494 *bytes_slot = total_bytes;
@@ -496,10 +496,10 @@
496 496 }
497 497 BackendEvent::ImportWalkComplete { total, total_bytes } => {
498 498 let loose_files = matches!(
499 - &self.import_mode,
499 + &self.import_wf.import_mode,
500 500 ImportMode::Importing { loose_files: true, .. }
501 501 );
502 - self.import_mode = ImportMode::Importing {
502 + self.import_wf.import_mode = ImportMode::Importing {
503 503 total,
504 504 completed: 0,
505 505 current_name: String::new(),
@@ -514,11 +514,11 @@
514 514 total,
515 515 current_name,
516 516 } => {
517 - let (prev_bytes, loose_files) = match &self.import_mode {
517 + let (prev_bytes, loose_files) = match &self.import_wf.import_mode {
518 518 ImportMode::Importing { total_bytes, loose_files, .. } => (*total_bytes, *loose_files),
519 519 _ => (0, false),
520 520 };
521 - self.import_mode = ImportMode::Importing {
521 + self.import_wf.import_mode = ImportMode::Importing {
522 522 total,
523 523 completed,
524 524 current_name,
@@ -529,7 +529,7 @@
529 529 };
530 530 }
531 531 BackendEvent::ImportFileError { path, error } => {
532 - self.import_file_errors.push(super::ImportFileError { path, error });
532 + self.import_wf.import_file_errors.push(super::ImportFileError { path, error });
533 533 }
534 534 BackendEvent::ImportComplete {
535 535 imported,
@@ -538,7 +538,7 @@
538 538 duplicates,
539 539 folders,
540 540 } => {
541 - self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| {
541 + self.nav.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| {
542 542 error!("Failed to refresh VFS list: {e}");
543 543 Vec::new()
544 544 }));
@@ -546,7 +546,7 @@
546 546 self.refresh_all_tags();
547 547 // First successful import auto-dismisses the welcome hint —
548 548 // the user has moved past onboarding.
549 - if !imported.is_empty() && self.show_first_launch_hint {
549 + if !imported.is_empty() && self.onboarding.show_first_launch_hint {
550 550 self.dismiss_first_launch_hint();
551 551 }
552 552
@@ -559,7 +559,7 @@
559 559 }
560 560 self.status = parts.join(", ");
561 561
562 - if !folders.is_empty() && !self.quick_import {
562 + if !folders.is_empty() && !self.import_wf.quick_import {
563 563 let entries = folders
564 564 .into_iter()
565 565 .map(|f| FolderTagEntry {
@@ -570,22 +570,22 @@
570 570 tag_input: String::new(),
571 571 })
572 572 .collect();
573 - self.import_mode = ImportMode::TagFolders {
573 + self.import_wf.import_mode = ImportMode::TagFolders {
574 574 entries,
575 575 sample_hashes: imported,
576 576 };
577 577 } else if !imported.is_empty() {
578 - if self.quick_import {
578 + if self.import_wf.quick_import {
579 579 // Skip ConfigureAnalysis — run with defaults immediately
580 580 self.run_analysis(imported, AnalysisConfig::default());
581 581 } else {
582 582 self.start_analysis_flow(imported);
583 583 }
584 584 } else if self.has_import_errors() {
585 - self.import_mode = ImportMode::ReviewErrors;
585 + self.import_wf.import_mode = ImportMode::ReviewErrors;
586 586 } else {
587 - self.quick_import = false;
588 - self.import_mode = ImportMode::None;
587 + self.import_wf.quick_import = false;
588 + self.import_wf.import_mode = ImportMode::None;
589 589 }
590 590 return true;
591 591 }
@@ -596,11 +596,11 @@
596 596 total,
597 597 current_name,
598 598 } => {
599 - if self.backfill_in_progress {
599 + if self.import_wf.backfill_in_progress {
600 600 // Silent: keep the normal browser view, just a status line.
601 601 self.status = format!("Backfilling features… {completed}/{total}");
602 602 } else {
603 - self.import_mode = ImportMode::Analyzing {
603 + self.import_wf.import_mode = ImportMode::Analyzing {
604 604 completed,
605 605 total,
606 606 current_name,
@@ -616,18 +616,18 @@
616 616 // ANALYSIS_SAVE_BATCH results and on AnalysisBatchComplete.
617 617 // (Rules are applied per flushed batch, after the save, so they
618 618 // still see the persisted analysis.)
619 - self.analysis_save_buffer.push(result.clone());
620 - if self.analysis_save_buffer.len() >= ANALYSIS_SAVE_BATCH {
619 + self.import_wf.analysis_save_buffer.push(result.clone());
620 + if self.import_wf.analysis_save_buffer.len() >= ANALYSIS_SAVE_BATCH {
621 621 self.flush_analysis_saves();
622 622 }
623 623
624 624 // Backfill: persist vectors + apply rules only — no review queue.
625 - if !self.backfill_in_progress {
625 + if !self.import_wf.backfill_in_progress {
626 626 let hash = audiofiles_core::SampleHash::from_trusted(result.hash.clone());
627 627 let name = self.backend.sample_original_name(&hash)
628 628 .unwrap_or_else(|_| hash.to_string());
629 629
630 - self.pending_review_items.push(ReviewItem {
630 + self.import_wf.pending_review_items.push(ReviewItem {
631 631 hash,
632 632 name,
633 633 suggestions: suggestions
@@ -644,17 +644,17 @@
644 644 BackendEvent::AnalysisSampleError { hash, error } => {
645 645 let name = self.backend.sample_original_name(&hash)
646 646 .unwrap_or_else(|_| hash.clone());
647 - self.analysis_errors.push(super::AnalysisFileError { hash, name, error });
647 + self.import_wf.analysis_errors.push(super::AnalysisFileError { hash, name, error });
648 648 }
649 649 BackendEvent::AnalysisBatchComplete => {
650 650 // Persist any analysis results still buffered, then apply rules,
651 651 // before the review/backfill bookkeeping below.
652 652 self.flush_analysis_saves();
653 - if self.backfill_in_progress {
654 - self.backfill_in_progress = false;
655 - let errors = self.analysis_errors.len();
656 - self.analysis_errors.clear();
657 - self.pending_review_items.clear();
653 + if self.import_wf.backfill_in_progress {
654 + self.import_wf.backfill_in_progress = false;
655 + let errors = self.import_wf.analysis_errors.len();
656 + self.import_wf.analysis_errors.clear();
657 + self.import_wf.pending_review_items.clear();
658 658 self.refresh_contents();
659 659 self.status = if errors == 0 {
660 660 "Feature backfill complete".to_string()
@@ -663,20 +663,20 @@
663 663 };
664 664 return true;
665 665 }
666 - let items = std::mem::take(&mut self.pending_review_items);
667 - let error_count = self.analysis_errors.len();
668 - if self.quick_import {
666 + let items = std::mem::take(&mut self.import_wf.pending_review_items);
667 + let error_count = self.import_wf.analysis_errors.len();
668 + if self.import_wf.quick_import {
669 669 // Auto-accept all suggestions in quick mode
670 670 for item in &items {
671 671 for s in &item.suggestions {
672 672 super::log_backend_err("add_tag (import suggestion)", self.backend.add_tag(&item.hash, &s.suggestion.tag));
673 673 }
674 674 }
675 - self.quick_import = false;
675 + self.import_wf.quick_import = false;
676 676 self.refresh_contents();
677 677 self.refresh_vfs_list();
678 678 let count = items.len();
679 - self.import_mode = ImportMode::None;
679 + self.import_wf.import_mode = ImportMode::None;
680 680 self.status = if error_count == 0 {
681 681 format!("Quick import complete: {count} samples analyzed")
682 682 } else {
@@ -684,14 +684,14 @@
684 684 };
685 685 } else if items.is_empty() {
686 686 if self.has_import_errors() {
687 - self.import_mode = ImportMode::ReviewErrors;
687 + self.import_wf.import_mode = ImportMode::ReviewErrors;
688 688 } else {
689 - self.import_mode = ImportMode::None;
689 + self.import_wf.import_mode = ImportMode::None;
690 690 self.status = "Analysis complete, no results".to_string();
691 691 }
692 692 } else {
693 693 let count = items.len();
694 - self.import_mode = ImportMode::ReviewSuggestions {
694 + self.import_wf.import_mode = ImportMode::ReviewSuggestions {
695 695 items,
696 696 current_idx: 0,
697 697 sort: crate::state::ReviewSort::ImportOrder,
@@ -710,7 +710,7 @@
710 710 total,
711 711 current_name,
712 712 } => {
713 - self.import_mode = ImportMode::Exporting {
713 + self.import_wf.import_mode = ImportMode::Exporting {
714 714 completed,
715 715 total,
716 716 current_name,
@@ -724,7 +724,7 @@
724 724 self.status =
725 725 format!("Exported {total} files ({error_count} errors)");
726 726 }
727 - self.import_mode = ImportMode::ExportComplete { total, errors };
727 + self.import_wf.import_mode = ImportMode::ExportComplete { total, errors };
728 728 return true;
729 729 }
730 730
@@ -734,7 +734,7 @@
734 734 total,
735 735 current_name,
736 736 } => {
737 - self.import_mode = ImportMode::Cleaning {
737 + self.import_wf.import_mode = ImportMode::Cleaning {
738 738 completed,
739 739 total,
740 740 current_name,
@@ -751,24 +751,24 @@
751 751 } else {
752 752 self.status = "Library deleted".to_string();
753 753 }
754 - self.import_mode = ImportMode::None;
754 + self.import_wf.import_mode = ImportMode::None;
755 755 return true;
756 756 }
757 757
758 758 // --- Loose-files maintenance events ---
759 759 BackendEvent::LooseFilesIntegrity { missing } => {
760 - self.loose_files_busy = false;
761 - self.loose_files_missing_count = missing;
760 + self.loose_files.loose_files_busy = false;
761 + self.loose_files.loose_files_missing_count = missing;
762 762 if missing > 0 {
763 - self.show_loose_files_warning = true;
763 + self.loose_files.show_loose_files_warning = true;
764 764 }
765 765 }
766 766 BackendEvent::LooseFilesRelocated {
767 767 relocated,
768 768 still_missing,
769 769 } => {
770 - self.loose_files_busy = false;
771 - self.loose_files_missing_count = still_missing;
770 + self.loose_files.loose_files_busy = false;
771 + self.loose_files.loose_files_missing_count = still_missing;
772 772 self.status = if relocated == 0 {
773 773 "No matching files found in that folder.".to_string()
774 774 } else if still_missing == 0 {
@@ -777,19 +777,19 @@
777 777 format!("Relocated {relocated} samples. {still_missing} still missing.")
778 778 };
779 779 if still_missing == 0 {
780 - self.show_loose_files_warning = false;
780 + self.loose_files.show_loose_files_warning = false;
781 781 }
782 782 self.refresh_contents();
783 783 }
784 784 BackendEvent::LooseFilesPurged { purged } => {
785 - self.loose_files_busy = false;
785 + self.loose_files.loose_files_busy = false;
786 786 self.status = format!("Purged {purged} missing samples");
787 - self.loose_files_missing_count = 0;
788 - self.show_loose_files_warning = false;
787 + self.loose_files.loose_files_missing_count = 0;
788 + self.loose_files.show_loose_files_warning = false;
789 789 self.refresh_contents();
790 790 }
791 791 BackendEvent::LooseFilesFailed { message } => {
792 - self.loose_files_busy = false;
792 + self.loose_files.loose_files_busy = false;
793 793 self.status = format!("Loose-files operation failed: {message}");
794 794 }
795 795
@@ -874,7 +874,7 @@
874 874 BackendEvent::SearchError { error } => {
875 875 // Clear the in-flight request so the repaint-while-busy guard
876 876 // stops; a failed search is terminal just like a result.
877 - self.latest_similarity_request = None;
877 + self.search.latest_similarity_request = None;
878 878 self.status = format!("Search failed: {error}");
879 879 }
880 880
@@ -901,7 +901,7 @@
901 901 /// of N/M files); falls through to `None` when no files have committed yet
902 902 /// (walking phase, or cancel before start).
903 903 pub fn cancel_import(&mut self) {
904 - let progress = match &self.import_mode {
904 + let progress = match &self.import_wf.import_mode {
905 905 ImportMode::Importing {
906 906 completed, total, walking, ..
907 907 } if !*walking && *total > 0 => Some((*completed, *total)),
@@ -911,10 +911,10 @@
911 911 // Clear the quick-import flag: it's set when a quick import starts and
912 912 // otherwise only resets on completion, so a cancel mid-import would leak
913 913 // it into the next (normal) import and skip its tagging/config steps.
914 - self.quick_import = false;
914 + self.import_wf.quick_import = false;
915 915 self.refresh_contents();
916 916 self.status = "Import cancelled".to_string();
917 - self.import_mode = match progress {
917 + self.import_wf.import_mode = match progress {
918 918 Some((completed, total)) => ImportMode::OperationCancelled {
919 919 kind: crate::state::CancelKind::Import,
920 920 completed,
@@ -929,7 +929,7 @@
929 929 /// the user can adjust settings and retry.
930 930 pub fn retry_import(&mut self) {
931 931 self.cancel_import();
932 - if let Some(source) = self.last_import_source.clone() {
932 + if let Some(source) = self.import_wf.last_import_source.clone() {
933 933 self.show_import_options(source);
934 934 }
935 935 }
@@ -937,8 +937,8 @@
937 937 /// Cancel the current analysis and restart it with the same parameters.
938 938 pub fn retry_analysis(&mut self) {
939 939 self.cancel_analysis();
940 - let hashes = std::mem::take(&mut self.last_analysis_hashes);
941 - if let Some(config) = self.last_analysis_config.take()
940 + let hashes = std::mem::take(&mut self.import_wf.last_analysis_hashes);
941 + if let Some(config) = self.import_wf.last_analysis_config.take()
942 942 && !hashes.is_empty() {
943 943 self.run_analysis(hashes, config);
944 944 }
@@ -946,8 +946,8 @@
946 946
947 947 /// Apply user-entered tags to each imported folder's samples, then start analysis.
Lines truncated
@@ -16,18 +16,18 @@
16 16 /// Reload the VFS list from the database and reset navigation to root.
17 17 pub fn refresh_vfs_list(&mut self) {
18 18 match self.backend.list_vfs() {
19 - Ok(list) => self.vfs_list = Arc::new(list),
19 + Ok(list) => self.nav.vfs_list = Arc::new(list),
20 20 Err(e) => {
21 21 error!("Failed to refresh VFS list: {e}");
22 22 return;
23 23 }
24 24 }
25 - if self.current_vfs_idx >= self.vfs_list.len() {
26 - self.current_vfs_idx = 0;
25 + if self.nav.current_vfs_idx >= self.nav.vfs_list.len() {
26 + self.nav.current_vfs_idx = 0;
27 27 }
28 - self.current_dir = None;
29 - self.breadcrumb.clear();
30 - self.selection.clear();
28 + self.nav.current_dir = None;
29 + self.nav.breadcrumb.clear();
30 + self.nav.selection.clear();
31 31 self.refresh_contents();
32 32 self.refresh_collections();
33 33 self.check_loose_files_integrity();
@@ -37,13 +37,13 @@
37 37 /// and shows the warning overlay if any source files are missing.
38 38 pub fn check_loose_files_integrity(&mut self) {
39 39 if !self.settings.is_loose_files {
40 - self.loose_files_missing_count = 0;
40 + self.loose_files.loose_files_missing_count = 0;
41 41 return;
42 42 }
43 43 // Runs off the GUI thread: it stats every loose source path. The result
44 44 // arrives as BackendEvent::LooseFilesIntegrity (see poll_workers).
45 45 match self.backend.start_loose_files_check() {
46 - Ok(()) => self.loose_files_busy = true,
46 + Ok(()) => self.loose_files.loose_files_busy = true,
47 47 Err(e) => warn!("Loose-files integrity check failed to start: {e}"),
48 48 }
49 49 }
@@ -53,7 +53,7 @@
53 53 pub fn purge_missing_loose_files(&mut self) {
54 54 match self.backend.start_loose_files_purge() {
55 55 Ok(()) => {
56 - self.loose_files_busy = true;
56 + self.loose_files.loose_files_busy = true;
57 57 self.status = "Purging missing samples\u{2026}".to_string();
58 58 }
59 59 Err(e) => self.status = format!("Purge failed: {e}"),
@@ -62,7 +62,7 @@
62 62
63 63 /// Dismiss the loose-files integrity warning without purging.
64 64 pub fn dismiss_loose_files_warning(&mut self) {
65 - self.show_loose_files_warning = false;
65 + self.loose_files.show_loose_files_warning = false;
66 66 }
67 67
68 68 /// Locate missing loose-files mode samples by walking `search_root` and
@@ -75,7 +75,7 @@
75 75 // BackendEvent::LooseFilesRelocated (see poll_workers).
76 76 match self.backend.start_loose_files_relocate(&search_root) {
77 77 Ok(()) => {
78 - self.loose_files_busy = true;
78 + self.loose_files.loose_files_busy = true;
79 79 self.status = "Searching that folder for missing samples\u{2026}".to_string();
80 80 }
81 81 Err(e) => self.status = format!("Could not start locate \u{2014} {e}"),
@@ -141,14 +141,14 @@
141 141
142 142 /// Dismiss the first-launch hint and persist the preference.
143 143 pub fn dismiss_first_launch_hint(&mut self) {
144 - self.show_first_launch_hint = false;
144 + self.onboarding.show_first_launch_hint = false;
145 145 super::log_backend_err("set_config hints_dismissed", self.backend.set_config("hints_dismissed", "1"));
146 146 }
147 147
148 148 /// Re-surface the welcome screen for a user who dismissed it. Persists the
149 149 /// reset so the welcome renders again on next launch until re-dismissed.
150 150 pub fn show_welcome(&mut self) {
151 - self.show_first_launch_hint = true;
151 + self.onboarding.show_first_launch_hint = true;
152 152 super::log_backend_err("set_config hints_dismissed", self.backend.set_config("hints_dismissed", "0"));
153 153 }
154 154
@@ -156,7 +156,7 @@
156 156 /// from the banner's "Maybe later" button and (implicitly) when the user
157 157 /// clicks "Set up sync" — the banner should not re-appear after either.
158 158 pub fn dismiss_sync_intro(&mut self) {
159 - self.show_sync_intro = false;
159 + self.onboarding.show_sync_intro = false;
160 160 super::log_backend_err("set_config sync_intro_dismissed", self.backend.set_config("sync_intro_dismissed", "1"));
161 161 }
162 162
@@ -164,15 +164,15 @@
164 164 /// library switch. Used to gate the library picker with a confirm modal so
165 165 /// an accidental click doesn't cancel an active import or bulk operation.
166 166 pub fn has_in_flight_work(&self) -> bool {
167 - !matches!(self.import_mode, crate::state::ImportMode::None)
167 + !matches!(self.import_wf.import_mode, crate::state::ImportMode::None)
168 168 || self.bulk_modal.is_some()
169 - || self.pending_import_preflight.is_some()
169 + || self.import_wf.pending_import_preflight.is_some()
170 170 }
171 171
172 172 /// Re-surface the VFS first-run banner ("a vault is your sample collection…").
173 173 /// Used from Settings / Help to bring back onboarding context after dismissal.
174 174 pub fn reset_vfs_explanation(&mut self) {
175 - self.show_vfs_banner = true;
175 + self.onboarding.show_vfs_banner = true;
176 176 super::log_backend_err("set_config vfs_explained", self.backend.set_config("vfs_explained", "0"));
177 177 }
178 178
@@ -185,7 +185,7 @@
185 185 self.refresh_all_tags();
186 186 // Also retire the old name from any active filter so the user
187 187 // isn't filtering on a tag that no longer exists.
188 - self.search_filter.required_tags.retain(|t| t != old_tag);
188 + self.search.search_filter.required_tags.retain(|t| t != old_tag);
189 189 self.apply_search();
190 190 self.status = format!("Renamed tag: {old_tag} to {new_tag} ({count} sample{})",
191 191 if count == 1 { "" } else { "s" });
@@ -204,7 +204,7 @@
204 204
205 205 /// Save the current search filter as a dynamic collection with the given name.
206 206 pub fn save_dynamic_collection(&mut self, name: &str) {
207 - match self.backend.create_dynamic_collection(name, &self.search_filter) {
207 + match self.backend.create_dynamic_collection(name, &self.search.search_filter) {
208 208 Ok(_) => {
209 209 self.status = format!("Saved collection: {name}");
210 210 }
@@ -217,13 +217,13 @@
217 217
218 218 /// Activate a dynamic collection: apply its filter and refresh.
219 219 pub fn activate_dynamic_collection(&mut self, _id: CollectionId, filter: &SearchFilter) {
220 - self.active_collection = None; // not a manual-collection view
221 - self.current_dir = None; // a filter view has no parent ".." row
222 - self.search_filter = filter.clone();
223 - self.search_query = filter.text_query.clone();
224 - self.similarity_search_hash = None;
225 - self.similarity_source_name = None;
226 - self.selection.clear();
220 + self.collections_ui.active_collection = None; // not a manual-collection view
221 + self.nav.current_dir = None; // a filter view has no parent ".." row
222 + self.search.search_filter = filter.clone();
223 + self.search.search_query = filter.text_query.clone();
224 + self.search.similarity_search_hash = None;
225 + self.search.similarity_source_name = None;
226 + self.nav.selection.clear();
227 227 self.refresh_contents();
228 228 }
229 229
@@ -231,7 +231,7 @@
231 231
232 232 /// Refresh the collection list from the database.
233 233 pub fn refresh_collections(&mut self) {
234 - self.collections = self.backend.list_collections()
234 + self.collections_ui.collections = self.backend.list_collections()
235 235 .unwrap_or_else(|e| {
236 236 warn!("Failed to load collections: {e}");
237 237 Vec::new()
@@ -252,18 +252,18 @@
252 252 let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hash_refs)
253 253 .unwrap_or_default();
254 254 let count = nodes.len();
255 - self.contents = ContentsRef::new(nodes);
256 - self.active_collection = Some(id);
257 - self.current_dir = None; // a collection view has no parent ".." row
258 - self.similarity_search_hash = None;
259 - self.similarity_source_name = None;
260 - self.selection.clear();
255 + self.nav.contents = ContentsRef::new(nodes);
256 + self.collections_ui.active_collection = Some(id);
257 + self.nav.current_dir = None; // a collection view has no parent ".." row
258 + self.search.similarity_search_hash = None;
259 + self.search.similarity_source_name = None;
260 + self.nav.selection.clear();
261 261 self.status = format!("{count} samples in collection");
262 262 }
263 263
264 264 /// Deactivate collection view and return to normal browsing.
265 265 pub fn deactivate_collection(&mut self) {
266 - self.active_collection = None;
266 + self.collections_ui.active_collection = None;
267 267 self.refresh_contents();
268 268 }
269 269
@@ -275,7 +275,7 @@
275 275 pub fn find_similar(&mut self, hash: &str) {
276 276 match self.backend.start_find_similar(hash, 50) {
277 277 Ok(()) => {
278 - self.latest_similarity_request = Some((hash.to_string(), false));
278 + self.search.latest_similarity_request = Some((hash.to_string(), false));
279 279 self.status = "Searching for similar samples...".to_string();
280 280 }
281 281 Err(e) => {
@@ -289,7 +289,7 @@
289 289 pub fn find_near_duplicates(&mut self, hash: &str) {
290 290 match self.backend.start_find_near_duplicates(hash, 50) {
291 291 Ok(()) => {
292 - self.latest_similarity_request = Some((hash.to_string(), true));
292 + self.search.latest_similarity_request = Some((hash.to_string(), true));
293 293 self.status = "Searching for near-duplicates...".to_string();
294 294 }
295 295 Err(e) => {
@@ -310,7 +310,7 @@
310 310 ) {
311 311 // Drop stale results: if the user fired another search after this one,
312 312 // the request won't match and a slow query can't clobber the view.
313 - if self.latest_similarity_request.as_ref()
313 + if self.search.latest_similarity_request.as_ref()
314 314 != Some(&(source_hash.to_string(), is_near_dup))
315 315 {
316 316 return;
@@ -321,25 +321,25 @@
321 321 .find_nodes_by_hashes(vfs_id, hashes)
322 322 .unwrap_or_default();
323 323 let count = nodes.len();
324 - self.contents = ContentsRef::new(nodes);
324 + self.nav.contents = ContentsRef::new(nodes);
325 325 // The results replace the folder view, so there is no parent ".." row;
326 326 // clear current_dir to keep the row-offset bookkeeping honest.
327 - self.current_dir = None;
327 + self.nav.current_dir = None;
328 328 // The in-flight request resolved — clear it so the repaint-while-busy
329 329 // guard stops and a later search starts from a clean slate.
330 - self.latest_similarity_request = None;
331 - self.similarity_search_hash = Some(source_hash.to_string());
332 - self.similarity_is_near_dup = is_near_dup;
333 - self.similarity_source_name = self.backend.sample_original_name(source_hash).ok();
334 - self.selection.clear();
330 + self.search.latest_similarity_request = None;
331 + self.search.similarity_search_hash = Some(source_hash.to_string());
332 + self.search.similarity_is_near_dup = is_near_dup;
333 + self.search.similarity_source_name = self.backend.sample_original_name(source_hash).ok();
334 + self.nav.selection.clear();
335 335 self.status = format!("Found {count} {noun}");
336 336 }
337 337
338 338 /// Clear similarity search mode and return to normal browsing.
339 339 pub fn clear_similarity_search(&mut self) {
340 - self.similarity_search_hash = None;
341 - self.similarity_source_name = None;
342 - self.latest_similarity_request = None;
340 + self.search.similarity_search_hash = None;
341 + self.search.similarity_source_name = None;
342 + self.search.latest_similarity_request = None;
343 343 self.refresh_contents();
344 344 }
345 345
@@ -365,8 +365,8 @@
365 365 pub fn reset_columns(&mut self) {
366 366 self.column_config = ColumnConfig::default();
367 367 self.row_height = 24.0;
368 - self.sort_column = SortColumn::Name;
369 - self.sort_direction = SortDirection::Ascending;
368 + self.nav.sort_column = SortColumn::Name;
369 + self.nav.sort_direction = SortDirection::Ascending;
370 370 self.save_column_config();
371 371 super::log_backend_err("set_config row_height", self.backend.set_config("row_height", "24"));
372 372 self.status = "Columns reset to defaults".to_string();
@@ -377,14 +377,14 @@
377 377 /// bulk operation). Used by Cmd+Shift+I and the matching menu items.
378 378 pub fn invert_selection(&mut self) {
379 379 let len = self.visible_len();
380 - self.selection.invert(len);
381 - if self.current_dir.is_some() {
382 - self.selection.selected.remove(&0);
383 - if self.selection.focus == 0 && len > 1 {
384 - self.selection.focus = 1;
380 + self.nav.selection.invert(len);
381 + if self.nav.current_dir.is_some() {
382 + self.nav.selection.selected.remove(&0);
383 + if self.nav.selection.focus == 0 && len > 1 {
384 + self.nav.selection.focus = 1;
385 385 }
386 - if self.selection.anchor == 0 && len > 1 {
387 - self.selection.anchor = 1;
386 + if self.nav.selection.anchor == 0 && len > 1 {
387 + self.nav.selection.anchor = 1;
388 388 }
389 389 }
390 390 self.refresh_selected_tags();
@@ -460,18 +460,18 @@
460 460
461 461 /// Mark the VFS mirror as needing a re-sync.
462 462 pub fn mark_mirror_dirty(&mut self) {
463 - if self.mirror_enabled {
464 - self.mirror_dirty = true;
463 + if self.mirror.mirror_enabled {
464 + self.mirror.mirror_dirty = true;
465 465 }
466 466 }
467 467
468 468 /// Run a mirror sync if the dirty flag is set. Returns true if a sync ran.
469 469 pub fn sync_mirror_if_dirty(&mut self) -> bool {
470 - if !self.mirror_enabled || !self.mirror_dirty {
470 + if !self.mirror.mirror_enabled || !self.mirror.mirror_dirty {
471 471 return false;
472 472 }
473 - self.mirror_dirty = false;
474 - match self.backend.sync_vfs_mirror(&self.mirror_path) {
473 + self.mirror.mirror_dirty = false;
474 + match self.backend.sync_vfs_mirror(&self.mirror.mirror_path) {
475 475 Ok((dirs, links, removed)) => {
476 476 if dirs + links + removed > 0 {
477 477 tracing::debug!(dirs, links, removed, "Mirror synced");
@@ -487,33 +487,33 @@
487 487
488 488 /// Enable or disable the VFS mirror. Persists the setting.
489 489 pub fn set_mirror_enabled(&mut self, enabled: bool) {
490 - self.mirror_enabled = enabled;
490 + self.mirror.mirror_enabled = enabled;
491 491 let _ = self
492 492 .backend
493 493 .set_config("mirror_enabled", if enabled { "1" } else { "0" });
494 494
495 495 if enabled {
496 496 // Run initial sync immediately.
497 - self.mirror_dirty = true;
497 + self.mirror.mirror_dirty = true;
498 498 self.sync_mirror_if_dirty();
499 499 } else {
500 500 // Remove the mirror directory.
501 - let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path);
501 + let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror.mirror_path);
502 502 }
503 503 }
504 504
505 505 /// Set the mirror path. Persists the setting.
506 506 pub fn set_mirror_path(&mut self, path: PathBuf) {
507 507 // If mirror is enabled, remove old mirror before switching.
508 - if self.mirror_enabled {
509 - let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path);
508 + if self.mirror.mirror_enabled {
509 + let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror.mirror_path);
510 510 }
511 - self.mirror_path = path;
511 + self.mirror.mirror_path = path;
512 512 let _ = self
513 513 .backend
514 - .set_config("mirror_path", &self.mirror_path.to_string_lossy());
515 - if self.mirror_enabled {
516 - self.mirror_dirty = true;
514 + .set_config("mirror_path", &self.mirror.mirror_path.to_string_lossy());
515 + if self.mirror.mirror_enabled {
516 + self.mirror.mirror_dirty = true;
517 517 }
518 518 }
519 519 }
@@ -16,11 +16,10 @@
16 16 use audiofiles_core::analysis::AnalysisResult;
17 17 use audiofiles_core::db::Database;
18 18 use audiofiles_core::error::CoreError;
19 - use audiofiles_core::collections::Collection;
20 19 use audiofiles_core::search::SearchFilter;
21 20 use audiofiles_core::store::SampleStore;
22 21 use audiofiles_core::util::split_name_ext;
23 - use audiofiles_core::vfs::{NodeType, Vfs, VfsNode};
22 + use audiofiles_core::vfs::NodeType;
24 23 use audiofiles_core::{CollectionId, NodeId, VfsId};
25 24 pub use audiofiles_core::vfs::VfsNodeWithAnalysis;
26 25 use parking_lot::Mutex;
@@ -142,16 +141,11 @@
142 141 pub data_dir: PathBuf,
143 142 pub backend: Box<dyn Backend>,
144 143
145 - // Navigation
146 - pub vfs_list: Arc<Vec<Vfs>>,
147 - pub current_vfs_idx: usize,
148 - pub current_dir: Option<NodeId>,
149 - pub breadcrumb: Vec<VfsNode>,
150 - pub contents: ContentsRef,
144 + // Navigation (vault list, location, contents, selection, sort)
145 + pub nav: NavUiState,
151 146 /// Non-blocking native file dialogs (folder/file pickers, save). Results are
152 147 /// applied on the GUI thread via `draw_browser`'s poll.
153 148 pub dialogs: crate::ui::dialog::DialogManager,
154 - pub selection: Selection,
155 149 pub selected_tags: Arc<Vec<String>>,
156 150 /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag
157 151 /// absent from this map is manual. Refreshed alongside `selected_tags`.
@@ -173,46 +167,11 @@
173 167 pub detail_visible: bool,
174 168 pub sidebar_visible: bool,
175 169
176 - // Sort
177 - pub sort_column: SortColumn,
178 - pub sort_direction: SortDirection,
179 -
180 - // Search / filter
181 - pub search_query: String,
182 - pub search_filter: SearchFilter,
183 - pub filter_panel_open: bool,
184 - /// Set when the search text changes; the search is applied only once this
185 - /// is older than the debounce window, so each keystroke does not run a
186 - /// blocking DB query + re-sort on the GUI thread.
187 - pub search_debounce_at: Option<std::time::Instant>,
188 -
189 - // Dynamic collection (saved search) name input
190 - pub collection_filter_name_input: String,
191 -
192 - /// Free-form input bound to the filter panel's Tags section so users can
193 - /// add tag filters from inside the filter panel itself (M-5 closed the
194 - /// add/remove asymmetry — tag chips already had a remove X, but no entry).
195 - pub filter_tag_input: String,
196 -
197 - // Similarity search
198 - pub similarity_search_hash: Option<String>,
199 - /// The most recent similarity request as `(source_hash, is_near_dup)`. Async
200 - /// results that don't match it are stale (the user fired another search
201 - /// first) and are dropped, so a slow query can't replace the current view.
202 - pub latest_similarity_request: Option<(String, bool)>,
203 - /// Whether the active similarity view is a near-duplicate search (vs. a
204 - /// feature-similarity search). Persisted so `refresh_contents` can re-run the
205 - /// correct query when the view is mutated (delete/move) in similarity mode.
206 - pub similarity_is_near_dup: bool,
207 - /// Display name of the source sample for the active similarity / duplicate
208 - /// search. Cached so the breadcrumb can render "Similar to: <name>" without
209 - /// a backend lookup on every frame.
210 - pub similarity_source_name: Option<String>,
170 + // Search / filter / similarity
171 + pub search: SearchUiState,
211 172
212 173 // Tags cache
213 174 pub all_tags: Arc<Vec<String>>,
214 - /// Sidebar tag tree filter input.
215 - pub tag_search: String,
216 175
217 176 // Preview
218 177 pub previewing_hash: Option<String>,
@@ -241,89 +200,19 @@
241 200 pub midi_pending_action: Option<MidiAction>,
242 201
243 202 // Overlays
244 - pub show_help: bool,
245 - /// Help overlay tab: 0 = Shortcuts, 1 = Features.
246 - pub help_tab: u8,
247 - /// Set by the toolbar's Help menu when the user picks "About". The app
248 - /// layer polls this each frame and flips its own `show_about`. Lives in
249 - /// browser state (not app state) because the browser owns the toolbar.
250 - pub about_requested: bool,
251 - pub pending_confirm: Option<ConfirmAction>,
203 + pub overlay: OverlayUiState,
252 204
253 205 // VFS management modals
254 - pub vfs_create_input: String,
255 - pub vfs_rename_target: Option<(VfsId, String)>,
256 - pub dir_create_input: String,
257 - pub show_vfs_create: bool,
258 - pub show_dir_create: bool,
259 - pub dir_rename_target: Option<(NodeId, String)>,
206 + pub vfs_modal: VfsModalUiState,
260 207
261 208 // Bulk operations
262 209 pub undo_stack: Vec<UndoOp>,
263 210 pub bulk_modal: Option<BulkModal>,
264 211 pub column_config: ColumnConfig,
265 212
266 - // Analysis
267 - pub import_mode: ImportMode,
268 - /// When true, the import flow skips ConfigureImport, TagFolders, and ConfigureAnalysis.
269 - pub quick_import: bool,
270 - /// Pending Quick-Import awaiting user confirmation. Set when the picked
271 - /// folder exceeds the preflight thresholds in `import_workflow.rs`.
272 - pub pending_import_preflight: Option<crate::state::import_workflow::ImportPreflight>,
273 - // M-9: persistent dismissal of the import preflight modal; loaded from
274 - // config in new() so the user's prior choice survives restart.
275 - pub import_preflight_disabled: bool,
276 - // M-9: transient checkbox state for the "Don't ask again" affordance.
277 - // Reset to false on every modal close path.
278 - pub preflight_dont_ask: bool,
279 - // M-2: search input on the Shortcuts help tab; filters the grid live.
280 - pub help_shortcut_search: String,
281 - // M-6: search input on the Bulk Move modal; filters the directory list.
282 - pub bulk_move_filter: String,
283 - pub pending_review_items: Vec<ReviewItem>,
213 + // Import / analysis workflow
214 + pub import_wf: ImportWorkflowUiState,
284 215
285 - /// Analysis results awaiting a batched DB write. The analysis worker emits
286 - /// results far faster than per-row autocommits can fsync, so they accumulate
287 - /// here and flush in one transaction (see `flush_analysis_saves`).
288 - pub analysis_save_buffer: Vec<audiofiles_core::analysis::AnalysisResult>,
289 -
290 - /// True while a silent feature-backfill batch is running (reuses the analysis
291 - /// worker but suppresses the review/progress screens). See `start_backfill`.
292 - pub backfill_in_progress: bool,
293 -
294 - // Error accumulation for import/analysis workflows
295 - pub import_file_errors: Vec<ImportFileError>,
296 - pub analysis_errors: Vec<AnalysisFileError>,
297 - pub import_errors_expanded: bool,
298 -
299 - // Retry state: last import source path so the user can restart from the config screen.
300 - pub last_import_source: Option<PathBuf>,
301 - // Retry state: last analysis parameters so the user can restart analysis.
302 - pub last_analysis_hashes: Vec<(String, String)>,
303 - pub last_analysis_config: Option<AnalysisConfig>,
304 - /// Destination of the in-flight export. Stashed when `run_export` spawns
305 - /// so the cancel-acknowledgement screen (C-3) can surface "files already
306 - /// written to <destination> remain".
307 - pub last_export_destination: Option<PathBuf>,
308 - /// Stashed folder-tag entries from the most recent TagFolders pass so the
309 - /// Back button on the ConfigureAnalysis screen can rehydrate the previous
310 - /// state (C-1). Tags themselves are `INSERT OR IGNORE` so re-applying after
311 - /// a Back is a no-op for the backend.
312 - #[allow(clippy::type_complexity)] // a snapshot tuple for Back-button rehydration; a named alias would not earn its keep
313 - pub last_folder_tags: Option<(Vec<crate::state::FolderTagEntry>, Vec<(String, String)>)>,
314 - /// Rolling progress samples for the current long-running operation
315 - /// (import / analysis / export). Drives the rate + ETA readout (M-11).
316 - /// Reset when an operation starts; consulted by the corresponding draw fn.
317 - pub operation_progress: Option<crate::state::OperationProgress>,
318 - /// Tag input on the Tag Folders screen's "Apply to all" row (M-9).
319 - /// Persists across frames so the user can type the value, then click the
320 - /// commit button. Reset when leaving the screen via Back / Skip / Apply.
321 - pub tag_folders_apply_all_input: String,
322 - /// Last backend error from a name-modal submit (vault create/rename, folder
323 - /// create/rename). Surfaces inline so the modal can stay open on failure
324 - /// rather than discarding the user's typed input (C-3). Cleared on modal
325 - /// open / successful submit / explicit Cancel.
326 - pub name_modal_error: Option<String>,
327 216 /// Set by "/" keyboard shortcut to focus the search bar on the next frame.
328 217 pub focus_search: bool,
329 218 /// Set by Tab from the file table to focus the detail-panel tag input on the next frame.
@@ -340,26 +229,11 @@
340 229 /// the dismiss, then fades. `None` means there's nothing to undo right
341 230 /// now (initial state or after a successful undo / timeout).
342 231 pub last_dismissed_suggestion: Option<(String, String, Instant)>,
343 - /// Set by keyboard navigation to scroll the file list to the focused row.
344 - pub scroll_to_row: Option<usize>,
345 -
346 232 // Theme
347 233 pub current_theme_id: String,
348 234
349 235 // Collections
350 - pub collections: Vec<Collection>,
351 - pub active_collection: Option<CollectionId>,
352 - pub collection_create_input: String,
353 - pub collection_rename_target: Option<(CollectionId, String)>,
354 - /// Inline rename for a tag in the sidebar: `(old_tag, new_name_buffer)`.
355 - /// Submission calls `backend.rename_tag_globally` and refreshes the tag list.
356 - pub tag_rename_target: Option<(String, String)>,
357 - /// Cached preview for the active rename: `(affected_sample_count, descendant_tags)`.
358 - /// Computed when `tag_rename_target` is opened (M-12); cleared when modal closes.
359 - /// Descendants are listed so the user knows they will NOT be renamed (the
360 - /// backend's `rename_tag_globally` is exact-match-only).
361 - pub tag_rename_preview: Option<(usize, Vec<String>)>,
362 - pub show_collection_create: bool,
236 + pub collections_ui: CollectionsUiState,
363 237
364 238 // Edit — floating editor window
365 239 pub edit: EditUiState,
@@ -379,12 +253,7 @@
379 253 pub row_height: f32,
380 254
381 255 // First-run onboarding
382 - pub show_vfs_banner: bool,
383 - /// Show "Right-click for options · F1 for shortcuts" hint until dismissed.
384 - pub show_first_launch_hint: bool,
385 - /// Show the "set up cloud sync to back up your library" banner. Surfaces
386 - /// once after the first successful import; persisted via `sync_intro_dismissed`.
387 - pub show_sync_intro: bool,
256 + pub onboarding: OnboardingUiState,
388 257
389 258 // Drag-out
390 259 /// Set when an OS drag fires; prevents re-triggering until the pointer is
@@ -392,9 +261,7 @@
392 261 pub os_drag_cooldown: Option<Instant>,
393 262
394 263 // VFS mirror
395 - pub mirror_enabled: bool,
396 - pub mirror_path: PathBuf,
397 - pub mirror_dirty: bool,
264 + pub mirror: MirrorUiState,
398 265
399 266 // Sync
400 267 pub sync: SyncUiState,
@@ -406,14 +273,7 @@
406 273 pub classifier: ClassifierUiState,
407 274
408 275 // Loose-files mode integrity
409 - /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files).
410 - pub loose_files_missing_count: usize,
411 - /// Whether to show the integrity warning overlay.
412 - pub show_loose_files_warning: bool,
413 - /// True while a loose-files maintenance op (check/relocate/purge) runs on its
414 - /// worker. Drives a repaint guard so the terminal event is drained without
415 - /// waiting for input, and a spinner-less overlay can't appear stuck.
416 - pub loose_files_busy: bool,
276 + pub loose_files: LooseFilesUiState,
417 277 }
418 278
419 279 impl BrowserState {
@@ -539,13 +399,14 @@
539 399 Ok(Self {
540 400 data_dir: data_dir.to_path_buf(),
541 401 backend,
542 - vfs_list: Arc::new(vfs_list),
543 - current_vfs_idx,
544 - current_dir: None,
545 - breadcrumb: Vec::new(),
546 - contents: ContentsRef::new(contents),
402 + nav: NavUiState {
403 + vfs_list: Arc::new(vfs_list),
404 + current_vfs_idx,
405 + contents: ContentsRef::new(contents),
406 + selection: Selection::new(),
407 + ..Default::default()
408 + },
547 409 dialogs: crate::ui::dialog::DialogManager::default(),
548 - selection: Selection::new(),
549 410 selected_tags: Arc::new(Vec::new()),
550 411 selected_tag_sources: std::collections::HashMap::new(),
551 412 selected_ml_suggestions: Vec::new(),
@@ -556,20 +417,11 @@
556 417 tag_input: String::new(),
557 418 detail_visible,
558 419 sidebar_visible,
559 - sort_column: SortColumn::Name,
560 - sort_direction: SortDirection::Ascending,
561 - search_query: String::new(),
562 - search_filter: SearchFilter::default(),
563 - filter_panel_open,
564 - search_debounce_at: None,
565 - collection_filter_name_input: String::new(),
566 - filter_tag_input: String::new(),
567 - similarity_search_hash: None,
568 - latest_similarity_request: None,
569 - similarity_is_near_dup: false,
570 - similarity_source_name: None,
420 + search: SearchUiState {
421 + filter_panel_open,
422 + ..Default::default()
423 + },
571 424 all_tags: Arc::new(all_tags),
572 - tag_search: String::new(),
573 425 previewing_hash: None,
574 426 shared,
575 427 sample_rate,
@@ -583,76 +435,47 @@
583 435 show_midi_window: false,
584 436 midi_state: MidiUiState::default(),
585 437 midi_pending_action: None,
586 - show_help: false,
587 - help_tab: 0,
588 - about_requested: false,
589 - pending_confirm: None,
590 - vfs_create_input: String::new(),
591 - vfs_rename_target: None,
592 - dir_create_input: String::new(),
593 - show_vfs_create: false,
594 - show_dir_create: false,
595 - dir_rename_target: None,
438 + overlay: OverlayUiState::default(),
439 + vfs_modal: VfsModalUiState::default(),
596 440 undo_stack: Vec::new(),
597 441 bulk_modal: None,
598 442 column_config: ColumnConfig::default(),
599 - import_mode: ImportMode::None,
600 - quick_import: false,
601 - pending_import_preflight: None,
602 - // M-9.
603 - import_preflight_disabled,
604 - preflight_dont_ask: false,
605 - // M-2.
606 - help_shortcut_search: String::new(),
607 - // M-6.
608 - bulk_move_filter: String::new(),
609 - pending_review_items: Vec::new(),
610 - analysis_save_buffer: Vec::new(),
611 - backfill_in_progress: false,
612 - import_file_errors: Vec::new(),
613 - analysis_errors: Vec::new(),
614 - import_errors_expanded: false,
615 - last_import_source: None,
616 - last_analysis_hashes: Vec::new(),
617 - last_analysis_config: None,
618 - last_export_destination: None,
619 - last_folder_tags: None,
620 - operation_progress: None,
621 - tag_folders_apply_all_input: String::new(),
622 - name_modal_error: None,
443 + import_wf: ImportWorkflowUiState {
444 + // M-9: persistent preflight dismissal loaded from config.
445 + import_preflight_disabled,
446 + ..Default::default()
447 + },
623 448 focus_search: false,
624 449 focus_tag_input: false,
625 450 focus_inline_editor: false,
626 451 dismissed_suggestions,
627 452 last_dismissed_suggestion: None,
628 - scroll_to_row: None,
629 453 current_theme_id: theme_id,
630 - collections: collections_list,
631 - active_collection: None,
632 - collection_create_input: String::new(),
633 - collection_rename_target: None,
634 - tag_rename_target: None,
635 - tag_rename_preview: None,
636 - show_collection_create: false,
454 + collections_ui: CollectionsUiState {
455 + collections: collections_list,
456 + ..Default::default()
457 + },
637 458 edit: EditUiState::default(),
638 459 forge: ForgeUiState::default(),
639 460 instrument_decode: crate::preview::InstrumentDecodeWorker::new(),
640 461 latest_chromatic_gen: 0,
641 462 row_height,
642 - show_vfs_banner: !vfs_explained,
643 - show_first_launch_hint: !hints_dismissed,
644 - // Suppressed until the first import completes (see import_workflow.rs).
645 - show_sync_intro: !sync_intro_dismissed,
463 + onboarding: OnboardingUiState {
464 + show_vfs_banner: !vfs_explained,
465 + show_first_launch_hint: !hints_dismissed,
466 + // Suppressed until the first import completes (see import_workflow.rs).
467 + show_sync_intro: !sync_intro_dismissed,
468 + },
646 469 os_drag_cooldown: None,
647 - mirror_enabled,
648 - mirror_path,
649 - mirror_dirty: mirror_enabled,
470 + mirror: MirrorUiState {
471 + mirror_enabled,
472 + mirror_path,
473 + mirror_dirty: mirror_enabled,
474 + },
650 475 sync: SyncUiState::default(),
651 476 settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() },
652 477 classifier: ClassifierUiState::default(),
653 - loose_files_missing_count: 0,
654 - show_loose_files_warning: false,
655 - loose_files_busy: false,
478 + loose_files: LooseFilesUiState::default(),
656 479 })
657 480 }
658 481
@@ -11,10 +11,24 @@
11 11 .cmp(b.chars().flat_map(char::to_lowercase))
12 12 }
13 13
14 + /// Total ordering for optional float columns (BPM, duration). `None` (no analysis
15 + /// yet) sorts before any value; two values compare via `total_cmp`, so a NaN from
16 + /// a corrupt analysis row gets a deterministic position instead of collapsing to
17 + /// `Equal` and making the sort non-total (which yields unstable row grouping).
18 + fn cmp_opt_f64(a: &Option<f64>, b: &Option<f64>) -> std::cmp::Ordering {
19 + use std::cmp::Ordering;
20 + match (a, b) {
21 + (Some(x), Some(y)) => x.total_cmp(y),
22 + (Some(_), None) => Ordering::Greater,
23 + (None, Some(_)) => Ordering::Less,
24 + (None, None) => Ordering::Equal,
25 + }
26 + }
27 +
14 28 impl BrowserState {
15 29 /// Database ID of the currently active VFS, or `None` if the list is empty.
16 30 pub fn current_vfs_id(&self) -> Option<VfsId> {
17 - self.vfs_list.get(self.current_vfs_idx).map(|v| v.id)
31 + self.nav.vfs_list.get(self.nav.current_vfs_idx).map(|v| v.id)
18 32 }
19 33
20 34 /// Reload the child node list and apply current sort/search.
@@ -24,8 +38,8 @@
24 38 // stale rows until the user exits the view, so re-run the same query; the
25 39 // fresh results replace the view via apply_similarity_results. (Previously
26 40 // this early-returned and did nothing, which was the staleness bug.)
27 - if let Some(hash) = self.similarity_search_hash.clone() {
28 - if self.similarity_is_near_dup {
41 + if let Some(hash) = self.search.similarity_search_hash.clone() {
42 + if self.search.similarity_is_near_dup {
29 43 self.find_near_duplicates(&hash);
30 44 } else {
31 45 self.find_similar(&hash);
@@ -36,43 +50,43 @@
36 50 let vfs_id = match self.current_vfs_id() {
37 51 Some(id) => id,
38 52 None => {
39 - self.contents = ContentsRef::new(Vec::new());
53 + self.nav.contents = ContentsRef::new(Vec::new());
40 54 return;
41 55 }
42 56 };
43 57
44 - if self.search_filter.is_active() || !self.search_query.is_empty() {
45 - let mut filter = self.search_filter.clone();
46 - filter.text_query = self.search_query.clone();
58 + if self.search.search_filter.is_active() || !self.search.search_query.is_empty() {
59 + let mut filter = self.search.search_filter.clone();
60 + filter.text_query = self.search.search_query.clone();
47 61 match filter.scope {
48 62 audiofiles_core::search::SearchScope::CurrentFolder => {
49 - match self.backend.search_in_folder(&filter, vfs_id, self.current_dir) {
50 - Ok(results) => self.contents = ContentsRef::new(results),
63 + match self.backend.search_in_folder(&filter, vfs_id, self.nav.current_dir) {
64 + Ok(results) => self.nav.contents = ContentsRef::new(results),
51 65 Err(e) => {
52 66 error!("Search failed: {e}");
53 67 self.status = "Search error".to_string();
54 - self.contents = ContentsRef::new(Vec::new());
68 + self.nav.contents = ContentsRef::new(Vec::new());
55 69 }
56 70 }
57 71 }
58 72 audiofiles_core::search::SearchScope::Global => {
59 73 match self.backend.search_global(&filter) {
60 - Ok(results) => self.contents = ContentsRef::new(results),
74 + Ok(results) => self.nav.contents = ContentsRef::new(results),
61 75 Err(e) => {
62 76 error!("Global search failed: {e}");
63 77 self.status = "Search error".to_string();
64 - self.contents = ContentsRef::new(Vec::new());
78 + self.nav.contents = ContentsRef::new(Vec::new());
65 79 }
66 80 }
67 81 }
68 82 }
69 83 } else {
70 - match self.backend.list_children_enriched(vfs_id, self.current_dir) {
71 - Ok(nodes) => self.contents = ContentsRef::new(nodes),
84 + match self.backend.list_children_enriched(vfs_id, self.nav.current_dir) {
85 + Ok(nodes) => self.nav.contents = ContentsRef::new(nodes),
72 86 Err(e) => {
73 87 error!("Failed to list directory: {e}");
74 88 self.status = "Failed to load contents".to_string();
75 - self.contents = ContentsRef::new(Vec::new());
89 + self.nav.contents = ContentsRef::new(Vec::new());
76 90 }
77 91 }
78 92 }
@@ -90,10 +104,10 @@
90 104 // sample — which the refresh_selected_detail below would then render as
91 105 // the wrong sample's waveform.
92 106 let len = self.visible_len();
93 - self.selection.clamp_to(len);
94 - if let Some(row) = self.scroll_to_row
107 + self.nav.selection.clamp_to(len);
108 + if let Some(row) = self.nav.scroll_to_row
95 109 && row >= len {
96 - self.scroll_to_row = if len == 0 { None } else { Some(len - 1) };
110 + self.nav.scroll_to_row = if len == 0 { None } else { Some(len - 1) };
97 111 }
98 112
99 113 self.refresh_selected_tags();
@@ -107,32 +121,29 @@
107 121
108 122 /// Apply current search query and filters.
109 123 pub fn apply_search(&mut self) {
110 - self.selection.clear();
124 + self.nav.selection.clear();
111 125 self.refresh_contents();
112 126 }
113 127
114 128 /// Sort contents by the current sort column and direction.
115 129 pub fn sort_contents(&mut self) {
116 130 // Directories always first
117 - self.contents.make_mut().sort_by(|a, b| {
131 + self.nav.contents.make_mut().sort_by(|a, b| {
118 132 let a_is_dir = a.node.node_type == NodeType::Directory;
119 133 let b_is_dir = b.node.node_type == NodeType::Directory;
120 134 if a_is_dir != b_is_dir {
121 135 return b_is_dir.cmp(&a_is_dir);
122 136 }
123 137
124 - let cmp = match self.sort_column {
138 + let cmp = match self.nav.sort_column {
125 139 SortColumn::Name => icase_cmp(&a.node.name, &b.node.name),
126 - SortColumn::Bpm => a.bpm.partial_cmp(&b.bpm).unwrap_or(std::cmp::Ordering::Equal),
140 + SortColumn::Bpm => cmp_opt_f64(&a.bpm, &b.bpm),
127 141 SortColumn::Key => a.musical_key.cmp(&b.musical_key),
128 - SortColumn::Duration => a
129 - .duration
130 - .partial_cmp(&b.duration)
131 - .unwrap_or(std::cmp::Ordering::Equal),
142 + SortColumn::Duration => cmp_opt_f64(&a.duration, &b.duration),
132 143 SortColumn::Classification => a.classification.cmp(&b.classification),
133 144 };
134 145
135 - match self.sort_direction {
146 + match self.nav.sort_direction {
136 147 SortDirection::Ascending => cmp,
137 148 SortDirection::Descending => cmp.reverse(),
138 149 }
@@ -141,17 +152,17 @@
141 152
142 153 /// Cycle sort for a column: ascending -> descending -> default(name asc).
143 154 pub fn toggle_sort(&mut self, column: SortColumn) {
144 - if self.sort_column == column {
145 - match self.sort_direction {
146 - SortDirection::Ascending => self.sort_direction = SortDirection::Descending,
155 + if self.nav.sort_column == column {
156 + match self.nav.sort_direction {
157 + SortDirection::Ascending => self.nav.sort_direction = SortDirection::Descending,
147 158 SortDirection::Descending => {
148 - self.sort_column = SortColumn::Name;
149 - self.sort_direction = SortDirection::Ascending;
159 + self.nav.sort_column = SortColumn::Name;
160 + self.nav.sort_direction = SortDirection::Ascending;
150 161 }
151 162 }
152 163 } else {
153 - self.sort_column = column;
154 - self.sort_direction = SortDirection::Ascending;
164 + self.nav.sort_column = column;
165 + self.nav.sort_direction = SortDirection::Ascending;
155 166 }
156 167 self.sort_contents();
157 168 }
@@ -193,34 +204,34 @@
193 204
194 205 /// Whether the file list currently shows a ".." parent-directory entry.
195 206 fn has_parent_entry(&self) -> bool {
196 - self.current_dir.is_some()
207 + self.nav.current_dir.is_some()
197 208 }
198 209
199 210 /// Total number of visible rows: contents + optional ".." parent entry.
200 211 pub fn visible_len(&self) -> usize {
201 - self.contents.len() + if self.has_parent_entry() { 1 } else { 0 }
212 + self.nav.contents.len() + if self.has_parent_entry() { 1 } else { 0 }
202 213 }
203 214
204 215 /// Return the VfsNodeWithAnalysis at the current selection focus, or `None` if ".." is selected.
205 216 pub fn selected_node(&self) -> Option<VfsNodeWithAnalysis> {
206 - let focus = self.selection.focus;
217 + let focus = self.nav.selection.focus;
207 218 if self.has_parent_entry() {
208 219 if focus == 0 {
209 220 return None; // ".." selected
210 221 }
211 - self.contents.get(focus - 1).cloned()
222 + self.nav.contents.get(focus - 1).cloned()
212 223 } else {
213 - self.contents.get(focus).cloned()
224 + self.nav.contents.get(focus).cloned()
214 225 }
215 226 }
216 227
217 228 /// Move selection focus down by one row (Down arrow).
218 229 pub fn select_next(&mut self) {
219 230 let len = self.visible_len();
220 - if len > 0 && self.selection.focus < len - 1 {
221 - let next = self.selection.focus + 1;
222 - self.selection.set_single(next);
223 - self.scroll_to_row = Some(next);
231 + if len > 0 && self.nav.selection.focus < len - 1 {
232 + let next = self.nav.selection.focus + 1;
233 + self.nav.selection.set_single(next);
234 + self.nav.scroll_to_row = Some(next);
224 235 self.refresh_selected_tags();
225 236 self.refresh_selected_detail();
226 237 }
@@ -228,10 +239,10 @@
228 239
229 240 /// Move selection focus up by one row (Up arrow).
230 241 pub fn select_prev(&mut self) {
231 - if self.selection.focus > 0 {
232 - let prev = self.selection.focus - 1;
233 - self.selection.set_single(prev);
234 - self.scroll_to_row = Some(prev);
242 + if self.nav.selection.focus > 0 {
243 + let prev = self.nav.selection.focus - 1;
244 + self.nav.selection.set_single(prev);
245 + self.nav.scroll_to_row = Some(prev);
235 246 self.refresh_selected_tags();
236 247 self.refresh_selected_detail();
237 248 }
@@ -239,22 +250,22 @@
239 250
240 251 /// Navigate into the selected directory, or go up if ".." is selected.
241 252 pub fn enter_directory(&mut self) {
242 - self.similarity_search_hash = None;
243 - self.similarity_source_name = None;
253 + self.search.similarity_search_hash = None;
254 + self.search.similarity_source_name = None;
244 255
245 - if self.has_parent_entry() && self.selection.focus == 0 {
256 + if self.has_parent_entry() && self.nav.selection.focus == 0 {
246 257 self.go_up();
247 258 return;
248 259 }
249 260
250 261 if let Some(node) = self.selected_node()
251 262 && node.node.node_type == NodeType::Directory {
252 - self.current_dir = Some(node.node.id);
253 - self.breadcrumb = self.backend.get_breadcrumb(node.node.id).unwrap_or_else(|e| {
263 + self.nav.current_dir = Some(node.node.id);
264 + self.nav.breadcrumb = self.backend.get_breadcrumb(node.node.id).unwrap_or_else(|e| {
254 265 warn!("Breadcrumb failed: {e}");
255 266 Vec::new()
256 267 });
257 - self.selection.clear();
268 + self.nav.selection.clear();
258 269 self.refresh_contents();
259 270 }
260 271 }
@@ -262,50 +273,50 @@
262 273 /// Navigate to the parent directory, or do nothing if already at root.
263 274 /// If a collection is active, exits collection view first.
264 275 pub fn go_up(&mut self) {
265 - self.similarity_search_hash = None;
266 - self.similarity_source_name = None;
267 - if self.active_collection.is_some() {
276 + self.search.similarity_search_hash = None;
277 + self.search.similarity_source_name = None;
278 + if self.collections_ui.active_collection.is_some() {
268 279 self.deactivate_collection();
269 280 return;
270 281 }
271 - if let Some(current) = self.current_dir {
282 + if let Some(current) = self.nav.current_dir {
272 283 if let Ok(node) = self.backend.get_node(current) {
273 - self.current_dir = node.parent_id;
284 + self.nav.current_dir = node.parent_id;
274 285 if let Some(pid) = node.parent_id {
275 - self.breadcrumb = self.backend.get_breadcrumb(pid).unwrap_or_else(|e| {
286 + self.nav.breadcrumb = self.backend.get_breadcrumb(pid).unwrap_or_else(|e| {
276 287 warn!("Breadcrumb failed: {e}");
277 288 Vec::new()
278 289 });
279 290 } else {
280 - self.breadcrumb.clear();
291 + self.nav.breadcrumb.clear();
281 292 }
282 293 } else {
283 - self.current_dir = None;
284 - self.breadcrumb.clear();
294 + self.nav.current_dir = None;
295 + self.nav.breadcrumb.clear();
285 296 }
286 - self.selection.clear();
297 + self.nav.selection.clear();
287 298 self.refresh_contents();
288 299 }
289 300 }
290 301
291 302 /// Switch to a different VFS by index, resetting navigation to its root.
292 303 pub fn select_vfs(&mut self, idx: usize) {
293 - if idx < self.vfs_list.len() && idx != self.current_vfs_idx {
294 - self.current_vfs_idx = idx;
295 - self.current_dir = None;
296 - self.breadcrumb.clear();
297 - self.selection.clear();
298 - self.similarity_search_hash = None;
299 - self.similarity_source_name = None;
304 + if idx < self.nav.vfs_list.len() && idx != self.nav.current_vfs_idx {
305 + self.nav.current_vfs_idx = idx;
306 + self.nav.current_dir = None;
307 + self.nav.breadcrumb.clear();
308 + self.nav.selection.clear();
309 + self.search.similarity_search_hash = None;
310 + self.search.similarity_source_name = None;
300 311 // Persist the selection by VFS id (not index — indices shift when
301 312 // vaults are added or removed) so it restores on next launch.
302 313 super::log_backend_err("set_config current_vfs_id", self.backend.set_config(
303 314 "current_vfs_id",
304 - &self.vfs_list[self.current_vfs_idx].id.as_i64().to_string(),
315 + &self.nav.vfs_list[self.nav.current_vfs_idx].id.as_i64().to_string(),
305 316 ));
306 317 self.refresh_contents();
307 318 self.refresh_collections();
308 - self.status = format!("Switched to: {}", self.vfs_list[self.current_vfs_idx].name);
319 + self.status = format!("Switched to: {}", self.nav.vfs_list[self.nav.current_vfs_idx].name);
309 320 }
310 321 }
311 322
@@ -338,10 +349,38 @@
338 349
339 350 /// Toggle the filter panel and persist the choice across restarts.
340 351 pub fn toggle_filter_panel(&mut self) {
341 - self.filter_panel_open = !self.filter_panel_open;
352 + self.search.filter_panel_open = !self.search.filter_panel_open;
342 353 super::log_backend_err("set_config filter_panel_open", self.backend.set_config(
343 354 "filter_panel_open",
344 - if self.filter_panel_open { "1" } else { "0" },
355 + if self.search.filter_panel_open { "1" } else { "0" },
345 356 ));
346 357 }
347 358 }
359 +
360 + #[cfg(test)]
361 + mod tests {
362 + use super::cmp_opt_f64;
363 + use std::cmp::Ordering;
364 +
365 + #[test]
366 + fn cmp_opt_f64_orders_values_and_none() {
367 + assert_eq!(cmp_opt_f64(&Some(1.0), &Some(2.0)), Ordering::Less);
368 + assert_eq!(cmp_opt_f64(&Some(2.0), &Some(1.0)), Ordering::Greater);
369 + assert_eq!(cmp_opt_f64(&Some(1.0), &Some(1.0)), Ordering::Equal);
370 + // None (no analysis) sorts before any value.
371 + assert_eq!(cmp_opt_f64(&None, &Some(1.0)), Ordering::Less);
372 + assert_eq!(cmp_opt_f64(&Some(1.0), &None), Ordering::Greater);
373 + assert_eq!(cmp_opt_f64(&None, &None), Ordering::Equal);
374 + }
375 +
376 + #[test]
377 + fn cmp_opt_f64_is_total_with_nan() {
378 + // A corrupt NaN row must produce a deterministic, total order rather than
379 + // collapsing to Equal (which made the old sort non-total).
380 + let nan = f64::NAN;
381 + assert_eq!(cmp_opt_f64(&Some(nan), &Some(nan)), Ordering::Equal);
382 + // total_cmp places NaN above all finite values, consistently both ways.
383 + assert_eq!(cmp_opt_f64(&Some(nan), &Some(1.0)), Ordering::Greater);
384 + assert_eq!(cmp_opt_f64(&Some(1.0), &Some(nan)), Ordering::Less);
385 + }
386 + }
@@ -31,14 +31,14 @@
31 31 fn add_sample_to_vfs(state: &BrowserState, hash: &str, name: &str) -> NodeId {
32 32 insert_fake_sample(state, hash);
33 33 let vfs_id = state.current_vfs_id().unwrap();
34 - let parent_id = state.current_dir;
34 + let parent_id = state.nav.current_dir;
35 35 state.backend.create_sample_link(vfs_id, parent_id, name, hash).unwrap()
36 36 }
37 37
38 38 /// Create a directory in the current VFS + directory and return its ID.
39 39 fn add_directory(state: &BrowserState, name: &str) -> NodeId {
40 40 let vfs_id = state.current_vfs_id().unwrap();
41 - let parent_id = state.current_dir;
41 + let parent_id = state.nav.current_dir;
42 42 state.backend.create_directory(vfs_id, parent_id, name).unwrap()
43 43 }
44 44
@@ -284,7 +284,7 @@
284 284 state.refresh_contents();
285 285
286 286 // Select both items
287 - state.selection.select_all(state.visible_len());
287 + state.nav.selection.select_all(state.visible_len());
288 288
289 289 // Open bulk tag modal
290 290 state.open_bulk_tag_modal();
@@ -327,7 +327,7 @@
327 327 state.backend.add_tag("h1", "mood.dark").unwrap();
328 328
329 329 // Select and open tag modal for removal
330 - state.selection.set_single(0);
330 + state.nav.selection.set_single(0);
331 331 state.open_bulk_tag_modal();
332 332 if let Some(BulkModal::Tag {
333 333 ref mut tag_input,
@@ -360,7 +360,7 @@
360 360 add_sample_to_vfs(&state, "h1", "kick.wav");
361 361 state.refresh_contents();
362 362
363 - state.selection.set_single(0);
363 + state.nav.selection.set_single(0);
364 364 state.open_bulk_tag_modal();
365 365 // Leave tag_input empty
366 366 state.execute_bulk_tag();
@@ -388,7 +388,7 @@
388 388
389 389 // Select the sample (not the directory)
390 390 // Contents are sorted: directories first. So index 0 = Drums, index 1 = kick.wav
391 - state.selection.set_single(1);
391 + state.nav.selection.set_single(1);
392 392 state.open_bulk_move_modal();
393 393 assert!(state.bulk_modal.is_some());
394 394
@@ -403,14 +403,14 @@
403 403
404 404 // Verify the node was moved: root should only have Drums now
405 405 state.refresh_contents();
406 - let root_samples: Vec<_> = state.contents.iter()
406 + let root_samples: Vec<_> = state.nav.contents.iter()
407 407 .filter(|n| n.node.node_type == NodeType::Sample)
408 408 .collect();
409 409 assert_eq!(root_samples.len(), 0);
410 410
411 411 // Undo should move it back
412 412 state.undo();
413 - let root_samples: Vec<_> = state.contents.iter()
413 + let root_samples: Vec<_> = state.nav.contents.iter()
414 414 .filter(|n| n.node.node_type == NodeType::Sample)
415 415 .collect();
416 416 assert_eq!(root_samples.len(), 1);
@@ -431,20 +431,20 @@
431 431 add_sample_to_vfs(&state, "h1", "kick.wav");
432 432 add_sample_to_vfs(&state, "h2", "snare.wav");
433 433 state.refresh_contents();
434 - assert_eq!(state.contents.len(), 2);
434 + assert_eq!(state.nav.contents.len(), 2);
435 435
436 436 // Select all and confirm delete
437 - state.selection.select_all(state.visible_len());
437 + state.nav.selection.select_all(state.visible_len());
438 438 state.confirm_delete_selection();
439 - assert!(state.pending_confirm.is_some());
439 + assert!(state.overlay.pending_confirm.is_some());
440 440
441 441 state.execute_confirmed_action();
442 - assert_eq!(state.contents.len(), 0);
442 + assert_eq!(state.nav.contents.len(), 0);
443 443 assert!(state.can_undo());
444 444
445 445 // Undo restores both nodes
446 446 state.undo();
447 - assert_eq!(state.contents.len(), 2);
447 + assert_eq!(state.nav.contents.len(), 2);
448 448 }
449 449
450 450 #[test]
@@ -453,14 +453,14 @@
453 453 insert_fake_sample(&state, "h1");
454 454 add_sample_to_vfs(&state, "h1", "kick.wav");
455 455 state.refresh_contents();
456 - assert_eq!(state.contents.len(), 1);
456 + assert_eq!(state.nav.contents.len(), 1);
457 457
458 - state.selection.set_single(0);
458 + state.nav.selection.set_single(0);
459 459 state.confirm_delete_selected();
460 - assert!(matches!(state.pending_confirm, Some(ConfirmAction::DeleteNode { .. })));
460 + assert!(matches!(state.overlay.pending_confirm, Some(ConfirmAction::DeleteNode { .. })));
461 461
462 462 state.execute_confirmed_action();
463 - assert_eq!(state.contents.len(), 0);
463 + assert_eq!(state.nav.contents.len(), 0);
464 464 }
465 465
466 466 #[test]
@@ -470,12 +470,12 @@
470 470 add_sample_to_vfs(&state, "h1", "kick.wav");
471 471 state.refresh_contents();
472 472
473 - state.selection.set_single(0);
473 + state.nav.selection.set_single(0);
474 474 state.confirm_delete_selected();
475 - assert!(state.pending_confirm.is_some());
475 + assert!(state.overlay.pending_confirm.is_some());
476 476
477 477 state.dismiss_confirm();
478 - assert!(state.pending_confirm.is_none());
478 + assert!(state.overlay.pending_confirm.is_none());
479 479 }
480 480
481 481 #[test]
@@ -485,7 +485,7 @@
485 485 add_sample_to_vfs(&state, "h1", "kick.wav");
486 486 state.refresh_contents();
487 487
488 - state.selection.set_single(0);
488 + state.nav.selection.set_single(0);
489 489 state.open_bulk_tag_modal();
490 490 assert!(state.bulk_modal.is_some());
491 491
@@ -502,12 +502,12 @@
502 502 state.refresh_contents();
503 503
504 504 // Navigate into the directory
505 - state.current_dir = Some(dir_id);
506 - state.breadcrumb = state.backend.get_breadcrumb(dir_id).unwrap();
505 + state.nav.current_dir = Some(dir_id);
506 + state.nav.breadcrumb = state.backend.get_breadcrumb(dir_id).unwrap();
507 507 state.refresh_contents();
508 508
509 509 // Now ".." is at index 0. Select index 0 ("..")
510 - state.selection.set_single(0);
510 + state.nav.selection.set_single(0);
511 511 let nodes = state.selected_nodes();
512 512 assert!(nodes.is_empty(), "'..' should be excluded from selected_nodes");
513 513 }
@@ -521,7 +521,7 @@
521 521 state.refresh_contents();
522 522
523 523 // Select all (dir + sample)
524 - state.selection.select_all(state.visible_len());
524 + state.nav.selection.select_all(state.visible_len());
525 525 let hashes = state.selected_sample_hashes();
526 526 assert_eq!(hashes.len(), 1);
527 527 assert_eq!(hashes[0], "h1");
@@ -536,18 +536,18 @@
536 536 add_sample_to_vfs(&state, "h2", "snare.wav");
537 537 state.backend.add_tag("h1", "genre.rock").unwrap();
538 538 state.refresh_contents();
539 - assert_eq!(state.contents.len(), 2);
539 + assert_eq!(state.nav.contents.len(), 2);
540 540
541 541 // Need 2+ items for DeleteMultiple (which has undo support)
542 - state.selection.select_all(state.visible_len());
542 + state.nav.selection.select_all(state.visible_len());
543 543 state.confirm_delete_selection();
544 - assert!(matches!(state.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })));
544 + assert!(matches!(state.overlay.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })));
545 545 state.execute_confirmed_action();
546 - assert_eq!(state.contents.len(), 0);
546 + assert_eq!(state.nav.contents.len(), 0);
547 547
548 548 // Undo restores nodes AND tags
549 549 state.undo();
550 - assert_eq!(state.contents.len(), 2);
550 + assert_eq!(state.nav.contents.len(), 2);
551 551 {
552 552 let tags = state.backend.get_sample_tags("h1").unwrap();
553 553 assert!(tags.contains(&"genre.rock".to_string()));
@@ -562,7 +562,7 @@
562 562 state.refresh_contents();
563 563
564 564 // First operation: add tag
565 - state.selection.set_single(0);
565 + state.nav.selection.set_single(0);
566 566 state.open_bulk_tag_modal();
567 567 if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal {
568 568 *tag_input = "genre.rock".to_string();
@@ -570,7 +570,7 @@
570 570 state.execute_bulk_tag();
571 571
572 572 // Second operation: add another tag
573 - state.selection.set_single(0);
573 + state.nav.selection.set_single(0);
574 574 state.open_bulk_tag_modal();
575 575 if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal {
576 576 *tag_input = "mood.dark".to_string();
@@ -620,9 +620,9 @@
620 620 state.refresh_contents();
621 621
622 622 state.start_export_flow(None);
623 - assert!(matches!(state.import_mode, ImportMode::ConfigureExport { .. }));
623 + assert!(matches!(state.import_wf.import_mode, ImportMode::ConfigureExport { .. }));
624 624
625 - if let ImportMode::ConfigureExport { ref items, ref config, .. } = state.import_mode {
625 + if let ImportMode::ConfigureExport { ref items, ref config, .. } = state.import_wf.import_mode {
626 626 assert_eq!(items.len(), 1);
627 627 assert!(matches!(config.format, audiofiles_core::export::ExportFormat::Original));
628 628 assert!(!config.flatten);
@@ -635,14 +635,14 @@
635 635 fn start_export_flow_empty_vfs_is_noop() {
636 636 let (mut state, _dir) = make_state();
637 637 state.start_export_flow(None);
638 - assert!(matches!(state.import_mode, ImportMode::None));
638 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
639 639 assert_eq!(state.status, "No samples to export");
640 640 }
641 641
642 642 #[test]
643 643 fn cancel_export_lands_in_acknowledgement() {
644 644 let (mut state, _dir) = make_state();
645 - state.import_mode = ImportMode::Exporting {
645 + state.import_wf.import_mode = ImportMode::Exporting {
646 646 completed: 3,
647 647 total: 10,
648 648 current_name: "test.wav".to_string(),
@@ -650,12 +650,12 @@
650 650 state.cancel_export();
651 651 // C-3: cancel with meaningful progress lands in the acknowledgement
652 652 // screen, not None, so the user sees what landed vs what was discarded.
653 - match state.import_mode {
653 + match state.import_wf.import_mode {
654 654 ImportMode::OperationCancelled { completed, total, .. } => {
655 655 assert_eq!(completed, 3);
656 656 assert_eq!(total, 10);
657 657 }
658 - _ => panic!("Expected OperationCancelled, got {:?}", std::mem::discriminant(&state.import_mode)),
658 + _ => panic!("Expected OperationCancelled, got {:?}", std::mem::discriminant(&state.import_wf.import_mode)),
659 659 }
660 660 assert_eq!(state.status, "Export cancelled");
661 661 }
@@ -665,13 +665,13 @@
665 665 let (mut state, _dir) = make_state();
666 666 // total == 0 means the export hasn't started — no meaningful progress
667 667 // to acknowledge. Falls through to None.
668 - state.import_mode = ImportMode::Exporting {
668 + state.import_wf.import_mode = ImportMode::Exporting {
669 669 completed: 0,
670 670 total: 0,
671 671 current_name: String::new(),
672 672 };
673 673 state.cancel_export();
674 - assert!(matches!(state.import_mode, ImportMode::None));
674 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
675 675 }
676 676
677 677 #[test]
@@ -689,24 +689,24 @@
689 689
690 690 // Step 1: start_export_flow → ConfigureExport
691 691 state.start_export_flow(None);
692 - assert!(matches!(state.import_mode, ImportMode::ConfigureExport { .. }));
692 + assert!(matches!(state.import_wf.import_mode, ImportMode::ConfigureExport { .. }));
693 693
694 694 // Step 2: Simulate transition to Exporting (run_export spawns a worker,
695 695 // but we test the state machine by setting it directly to avoid needing
696 696 // real audio files in the content-addressed store)
697 - state.import_mode = ImportMode::Exporting {
697 + state.import_wf.import_mode = ImportMode::Exporting {
698 698 completed: 0,
699 699 total: 1,
700 700 current_name: "kick.wav".to_string(),
701 701 };
702 - assert!(matches!(state.import_mode, ImportMode::Exporting { .. }));
702 + assert!(matches!(state.import_wf.import_mode, ImportMode::Exporting { .. }));
703 703
704 704 // Step 3: Simulate transition to ExportComplete
705 - state.import_mode = ImportMode::ExportComplete {
705 + state.import_wf.import_mode = ImportMode::ExportComplete {
706 706 total: 1,
707 707 errors: vec![],
708 708 };
709 - if let ImportMode::ExportComplete { total, ref errors } = state.import_mode {
709 + if let ImportMode::ExportComplete { total, ref errors } = state.import_wf.import_mode {
710 710 assert_eq!(total, 1);
711 711 assert!(errors.is_empty());
712 712 } else {
@@ -714,21 +714,21 @@
714 714 }
715 715
716 716 // Step 4: Reset to None (Done button)
717 - state.import_mode = ImportMode::None;
718 - assert!(matches!(state.import_mode, ImportMode::None));
717 + state.import_wf.import_mode = ImportMode::None;
718 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
719 719 }
720 720
721 721 #[test]
722 722 fn export_complete_with_errors() {
723 723 let (mut state, _dir) = make_state();
724 - state.import_mode = ImportMode::ExportComplete {
724 + state.import_wf.import_mode = ImportMode::ExportComplete {
725 725 total: 5,
726 726 errors: vec![
727 727 ("kick.wav".to_string(), "decode error".to_string()),
728 728 ("snare.wav".to_string(), "io error".to_string()),
729 729 ],
730 730 };
731 - if let ImportMode::ExportComplete { total, ref errors } = state.import_mode {
731 + if let ImportMode::ExportComplete { total, ref errors } = state.import_wf.import_mode {
732 732 assert_eq!(total, 5);
733 733 assert_eq!(errors.len(), 2);
734 734 } else {
@@ -745,14 +745,14 @@
745 745 #[test]
746 746 fn initial_import_mode_is_none() {
747 747 let (state, _dir) = make_state();
748 - assert!(matches!(state.import_mode, ImportMode::None));
748 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
749 749 }
750 750
751 751 #[test]
752 752 fn start_analysis_flow_empty_hashes_is_noop() {
753 753 let (mut state, _dir) = make_state();
754 754 state.start_analysis_flow(vec![]);
755 - assert!(matches!(state.import_mode, ImportMode::None));
755 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
756 756 }
757 757
758 758 #[test]
@@ -760,18 +760,18 @@
760 760 let (mut state, _dir) = make_state();
761 761 let hashes = vec![("abc".to_string(), "wav".to_string())];
762 762 state.start_analysis_flow(hashes);
763 - assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. }));
763 + assert!(matches!(state.import_wf.import_mode, ImportMode::ConfigureAnalysis { .. }));
764 764 }
765 765
766 766 #[test]
767 767 fn cancel_analysis_resets_state() {
768 768 let (mut state, _dir) = make_state();
769 - state.import_mode = ImportMode::Analyzing {
769 + state.import_wf.import_mode = ImportMode::Analyzing {
770 770 completed: 5,
771 771 total: 10,
772 772 current_name: "test.wav".to_string(),
773 773 };
774 - state.pending_review_items.push(ReviewItem {
774 + state.import_wf.pending_review_items.push(ReviewItem {
775 775 hash: audiofiles_core::SampleHash::from_trusted("abc"),
776 776 name: "test.wav".to_string(),
777 777 result: audiofiles_core::analysis::AnalysisResult {
@@ -805,14 +805,14 @@
805 805
806 806 state.cancel_analysis();
807 807 // C-3: progress > 0 → acknowledgement screen.
808 - match state.import_mode {
808 + match state.import_wf.import_mode {
809 809 ImportMode::OperationCancelled { completed, total, .. } => {
810 810 assert_eq!(completed, 5);
811 811 assert_eq!(total, 10);
812 812 }
813 813 _ => panic!("Expected OperationCancelled"),
814 814 }
815 - assert!(state.pending_review_items.is_empty());
815 + assert!(state.import_wf.pending_review_items.is_empty());
816 816 assert_eq!(state.status, "Analysis cancelled");
817 817 }
818 818
@@ -827,7 +827,7 @@
827 827 ref source_name,
828 828 ref available_vfs,
829 829 ..
830 - } = state.import_mode
830 + } = state.import_wf.import_mode
831 831 {
832 832 assert_eq!(s, &source);
833 833 assert_eq!(source_name, "test_samples");
@@ -840,7 +840,7 @@
840 840 #[test]
841 841 fn cancel_import_lands_in_acknowledgement() {
842 842 let (mut state, _dir) = make_state();
843 - state.import_mode = ImportMode::Importing {
843 + state.import_wf.import_mode = ImportMode::Importing {
844 844 total: 10,
845 845 completed: 3,
846 846 current_name: "file.wav".to_string(),
@@ -851,7 +851,7 @@
851 851 };
852 852 state.cancel_import();
853 853 // C-3: cancel during real progress lands in the acknowledgement screen.
854 - match state.import_mode {
854 + match state.import_wf.import_mode {
855 855 ImportMode::OperationCancelled { completed, total, .. } => {
856 856 assert_eq!(completed, 3);
857 857 assert_eq!(total, 10);
@@ -866,7 +866,7 @@
866 866 let (mut state, _dir) = make_state();
867 867 // walking == true means no files have committed yet; falls through to
868 868 // None so the user isn't shown a "Stopped at 0 of 0" screen.
869 - state.import_mode = ImportMode::Importing {
869 + state.import_wf.import_mode = ImportMode::Importing {
870 870 total: 0,
871 871 completed: 0,
872 872 current_name: String::new(),
@@ -876,15 +876,15 @@
876 876 loose_files: false,
877 877 };
878 878 state.cancel_import();
879 - assert!(matches!(state.import_mode, ImportMode::None));
879 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
880 880 }
881 881
882 882 #[test]
883 883 fn retry_import_reopens_config() {
884 884 let (mut state, _dir) = make_state();
885 885 let source = std::env::temp_dir().join("retry_test");
886 - state.last_import_source = Some(source.clone());
887 - state.import_mode = ImportMode::Importing {
886 + state.import_wf.last_import_source = Some(source.clone());
887 + state.import_wf.import_mode = ImportMode::Importing {
888 888 total: 10,
889 889 completed: 3,
890 890 current_name: "file.wav".to_string(),
@@ -894,14 +894,14 @@
894 894 loose_files: false,
895 895 };
896 896 state.retry_import();
897 - assert!(matches!(state.import_mode, ImportMode::ConfigureImport { .. }));
897 + assert!(matches!(state.import_wf.import_mode, ImportMode::ConfigureImport { .. }));
898 898 }
899 899
900 900 #[test]
901 901 fn retry_import_without_source_stays_cancelled() {
902 902 let (mut state, _dir) = make_state();
903 - state.last_import_source = None;
904 - state.import_mode = ImportMode::Importing {
903 + state.import_wf.last_import_source = None;
904 + state.import_wf.import_mode = ImportMode::Importing {
905 905 total: 10,
906 906 completed: 3,
907 907 current_name: "file.wav".to_string(),
@@ -914,7 +914,7 @@
914 914 // With no last_import_source, retry calls cancel_import which now lands
915 915 // in OperationCancelled (C-3). The retry path can't reopen config, so
916 916 // the user is left on the acknowledgement screen.
917 - assert!(matches!(state.import_mode, ImportMode::OperationCancelled { .. }));
917 + assert!(matches!(state.import_wf.import_mode, ImportMode::OperationCancelled { .. }));
918 918 }
919 919
920 920 #[test]
@@ -926,13 +926,13 @@
926 926 #[test]
927 927 fn apply_accepted_suggestions_resets_mode() {
928 928 let (mut state, _dir) = make_state();
929 - state.import_mode = ImportMode::ReviewSuggestions {
929 + state.import_wf.import_mode = ImportMode::ReviewSuggestions {
930 930 items: vec![],
931 931 current_idx: 0,
932 932 sort: crate::state::ReviewSort::ImportOrder,
933 933 };
934 934 state.apply_accepted_suggestions();
935 - assert!(matches!(state.import_mode, ImportMode::None));
935 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
936 936 }
937 937
938 938 #[test]
@@ -941,7 +941,7 @@
941 941 insert_fake_sample(&state, "h1");
942 942 add_sample_to_vfs(&state, "h1", "kick.wav");
943 943
944 - state.import_mode = ImportMode::TagFolders {
944 + state.import_wf.import_mode = ImportMode::TagFolders {
945 945 entries: vec![FolderTagEntry {
946 946 folder: crate::import::ImportedFolder {
947 947 name: "Drums".to_string(),
@@ -959,18 +959,18 @@
959 959 let tags = state.backend.get_sample_tags("h1").unwrap();
960 960 assert!(tags.contains(&"instrument.drum".to_string()));
961 961 }
962 - assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. }));
962 + assert!(matches!(state.import_wf.import_mode, ImportMode::ConfigureAnalysis { .. }));
963 963 }
964 964
965 965 #[test]
966 966 fn skip_folder_tags_goes_to_analysis() {
967 967 let (mut state, _dir) = make_state();
968 - state.import_mode = ImportMode::TagFolders {
968 + state.import_wf.import_mode = ImportMode::TagFolders {
969 969 entries: vec![],
970 970 sample_hashes: vec![("h1".to_string(), "wav".to_string())],
971 971 };
972 972 state.skip_folder_tags();
973 - assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. }));
973 + assert!(matches!(state.import_wf.import_mode, ImportMode::ConfigureAnalysis { .. }));
974 974 }
975 975
976 976 #[test]
@@ -979,7 +979,7 @@
979 979 insert_fake_sample(&state, "h1");
980 980 add_sample_to_vfs(&state, "h1", "kick.wav");
981 981
982 - state.import_mode = ImportMode::TagFolders {
982 + state.import_wf.import_mode = ImportMode::TagFolders {
983 983 entries: vec![FolderTagEntry {
984 984 folder: crate::import::ImportedFolder {
985 985 name: "Drums".to_string(),
@@ -1005,7 +1005,7 @@
1005 1005 insert_fake_sample(&state, "h1");
1006 1006 add_sample_to_vfs(&state, "h1", "kick.wav");
1007 1007
1008 - state.import_mode = ImportMode::TagFolders {
1008 + state.import_wf.import_mode = ImportMode::TagFolders {
1009 1009 entries: vec![FolderTagEntry {
1010 1010 folder: crate::import::ImportedFolder {
1011 1011 name: "Drums".to_string(),
@@ -1028,49 +1028,49 @@
1028 1028 #[test]
1029 1029 fn import_errors_accumulate() {
1030 1030 let (mut state, _dir) = make_state();
1031 - state.import_file_errors.push(super::ImportFileError {
1031 + state.import_wf.import_file_errors.push(super::ImportFileError {
1032 1032 path: "file1.wav".to_string(),
1033 1033 error: "decode error".to_string(),
1034 1034 });
1035 - state.import_file_errors.push(super::ImportFileError {
1035 + state.import_wf.import_file_errors.push(super::ImportFileError {
Lines truncated
@@ -373,6 +373,191 @@
373 373 pub match_count: Option<usize>,
374 374 }
375 375
376 + /// VFS navigation: the vault list, current location, folder contents, row
377 + /// selection, and sort order.
378 + #[derive(Default)]
379 + pub struct NavUiState {
380 + pub vfs_list: std::sync::Arc<Vec<audiofiles_core::vfs::Vfs>>,
381 + pub current_vfs_idx: usize,
382 + pub current_dir: Option<NodeId>,
383 + pub breadcrumb: Vec<VfsNode>,
384 + pub contents: super::ContentsRef,
385 + pub selection: Selection,
386 + pub sort_column: SortColumn,
387 + pub sort_direction: SortDirection,
388 + /// Set by keyboard navigation to scroll the file list to the focused row.
389 + pub scroll_to_row: Option<usize>,
390 + }
391 +
392 + /// Import / analysis workflow state: the active screen, retry snapshots,
393 + /// error accumulation, and pending review/save buffers.
394 + #[derive(Default)]
395 + pub struct ImportWorkflowUiState {
396 + pub import_mode: ImportMode,
397 + /// When true, the import flow skips ConfigureImport, TagFolders, and ConfigureAnalysis.
398 + pub quick_import: bool,
399 + /// Pending Quick-Import awaiting user confirmation. Set when the picked
400 + /// folder exceeds the preflight thresholds in `import_workflow.rs`.
401 + pub pending_import_preflight: Option<crate::state::import_workflow::ImportPreflight>,
402 + // M-9: persistent dismissal of the import preflight modal; loaded from
403 + // config in new() so the user's prior choice survives restart.
404 + pub import_preflight_disabled: bool,
405 + // M-9: transient checkbox state for the "Don't ask again" affordance.
406 + // Reset to false on every modal close path.
407 + pub preflight_dont_ask: bool,
408 + // M-6: search input on the Bulk Move modal; filters the directory list.
409 + pub bulk_move_filter: String,
410 + pub pending_review_items: Vec<ReviewItem>,
411 + /// Analysis results awaiting a batched DB write. The analysis worker emits
412 + /// results far faster than per-row autocommits can fsync, so they accumulate
413 + /// here and flush in one transaction (see `flush_analysis_saves`).
414 + pub analysis_save_buffer: Vec<audiofiles_core::analysis::AnalysisResult>,
415 + /// True while a silent feature-backfill batch is running (reuses the analysis
416 + /// worker but suppresses the review/progress screens). See `start_backfill`.
417 + pub backfill_in_progress: bool,
418 + // Error accumulation for import/analysis workflows
419 + pub import_file_errors: Vec<ImportFileError>,
420 + pub analysis_errors: Vec<AnalysisFileError>,
421 + pub import_errors_expanded: bool,
422 + // Retry state: last import source path so the user can restart from the config screen.
423 + pub last_import_source: Option<std::path::PathBuf>,
424 + // Retry state: last analysis parameters so the user can restart analysis.
425 + pub last_analysis_hashes: Vec<(String, String)>,
426 + pub last_analysis_config: Option<audiofiles_core::analysis::config::AnalysisConfig>,
427 + /// Destination of the in-flight export. Stashed when `run_export` spawns
428 + /// so the cancel-acknowledgement screen (C-3) can surface "files already
429 + /// written to <destination> remain".
430 + pub last_export_destination: Option<std::path::PathBuf>,
431 + /// Stashed folder-tag entries from the most recent TagFolders pass so the
432 + /// Back button on the ConfigureAnalysis screen can rehydrate the previous
433 + /// state (C-1). Tags themselves are `INSERT OR IGNORE` so re-applying after
434 + /// a Back is a no-op for the backend.
435 + #[allow(clippy::type_complexity)] // a snapshot tuple for Back-button rehydration; a named alias would not earn its keep
436 + pub last_folder_tags: Option<(Vec<FolderTagEntry>, Vec<(String, String)>)>,
437 + /// Rolling progress samples for the current long-running operation
438 + /// (import / analysis / export). Drives the rate + ETA readout (M-11).
439 + /// Reset when an operation starts; consulted by the corresponding draw fn.
440 + pub operation_progress: Option<OperationProgress>,
441 + /// Tag input on the Tag Folders screen's "Apply to all" row (M-9).
442 + /// Persists across frames so the user can type the value, then click the
443 + /// commit button. Reset when leaving the screen via Back / Skip / Apply.
444 + pub tag_folders_apply_all_input: String,
445 + }
446 +
447 + /// Search bar, filter panel, and similarity-search state.
448 + #[derive(Default)]
449 + pub struct SearchUiState {
450 + pub search_query: String,
451 + pub search_filter: audiofiles_core::search::SearchFilter,
452 + pub filter_panel_open: bool,
453 + /// Set when the search text changes; the search is applied only once this
454 + /// is older than the debounce window, so each keystroke does not run a
455 + /// blocking DB query + re-sort on the GUI thread.
456 + pub search_debounce_at: Option<std::time::Instant>,
457 + /// Free-form input bound to the filter panel's Tags section so users can
458 + /// add tag filters from inside the filter panel itself (M-5 closed the
459 + /// add/remove asymmetry — tag chips already had a remove X, but no entry).
460 + pub filter_tag_input: String,
461 + pub similarity_search_hash: Option<String>,
462 + /// The most recent similarity request as `(source_hash, is_near_dup)`. Async
463 + /// results that don't match it are stale (the user fired another search
464 + /// first) and are dropped, so a slow query can't replace the current view.
465 + pub latest_similarity_request: Option<(String, bool)>,
466 + /// Whether the active similarity view is a near-duplicate search (vs. a
467 + /// feature-similarity search). Persisted so `refresh_contents` can re-run the
468 + /// correct query when the view is mutated (delete/move) in similarity mode.
469 + pub similarity_is_near_dup: bool,
470 + /// Display name of the source sample for the active similarity / duplicate
471 + /// search. Cached so the breadcrumb can render "Similar to: <name>" without
472 + /// a backend lookup on every frame.
473 + pub similarity_source_name: Option<String>,
474 + /// Sidebar tag tree filter input.
475 + pub tag_search: String,
476 + }
477 +
478 + /// Loose-files mode integrity tracking.
479 + #[derive(Default)]
480 + pub struct LooseFilesUiState {
481 + /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files).
482 + pub loose_files_missing_count: usize,
483 + /// Whether to show the integrity warning overlay.
484 + pub show_loose_files_warning: bool,
485 + /// True while a loose-files maintenance op (check/relocate/purge) runs on its
486 + /// worker. Drives a repaint guard so the terminal event is drained without
487 + /// waiting for input, and a spinner-less overlay can't appear stuck.
488 + pub loose_files_busy: bool,
489 + }
490 +
491 + /// VFS mirror (folder export) settings.
492 + #[derive(Default)]
493 + pub struct MirrorUiState {
494 + pub mirror_enabled: bool,
495 + pub mirror_path: std::path::PathBuf,
496 + pub mirror_dirty: bool,
497 + }
498 +
499 + /// Collections sidebar: list, active selection, and create/rename inputs.
500 + #[derive(Default)]
501 + pub struct CollectionsUiState {
502 + pub collections: Vec<audiofiles_core::collections::Collection>,
503 + pub active_collection: Option<audiofiles_core::CollectionId>,
504 + pub collection_create_input: String,
505 + pub collection_rename_target: Option<(audiofiles_core::CollectionId, String)>,
506 + /// Inline rename for a tag in the sidebar: `(old_tag, new_name_buffer)`.
507 + /// Submission calls `backend.rename_tag_globally` and refreshes the tag list.
508 + pub tag_rename_target: Option<(String, String)>,
509 + /// Cached preview for the active rename: `(affected_sample_count, descendant_tags)`.
510 + /// Computed when `tag_rename_target` is opened (M-12); cleared when modal closes.
511 + /// Descendants are listed so the user knows they will NOT be renamed (the
512 + /// backend's `rename_tag_globally` is exact-match-only).
513 + pub tag_rename_preview: Option<(usize, Vec<String>)>,
514 + pub show_collection_create: bool,
515 + /// Dynamic collection (saved search) name input.
516 + pub collection_filter_name_input: String,
517 + }
518 +
519 + /// Help/about overlay and pending-confirmation dialog state.
520 + #[derive(Default)]
521 + pub struct OverlayUiState {
522 + pub show_help: bool,
523 + /// Help overlay tab: 0 = Shortcuts, 1 = Features.
524 + pub help_tab: u8,
525 + /// Set by the toolbar's Help menu when the user picks "About". The app
526 + /// layer polls this each frame and flips its own `show_about`. Lives in
527 + /// browser state (not app state) because the browser owns the toolbar.
528 + pub about_requested: bool,
529 + pub pending_confirm: Option<ConfirmAction>,
530 + /// M-2: search input on the Shortcuts help tab; filters the grid live.
531 + pub help_shortcut_search: String,
532 + }
533 +
534 + /// VFS/directory create + rename modal inputs and targets.
535 + #[derive(Default)]
536 + pub struct VfsModalUiState {
537 + pub vfs_create_input: String,
538 + pub vfs_rename_target: Option<(VfsId, String)>,
539 + pub dir_create_input: String,
540 + pub show_vfs_create: bool,
541 + pub show_dir_create: bool,
542 + pub dir_rename_target: Option<(NodeId, String)>,
543 + /// Last backend error from a name-modal submit (vault create/rename, folder
544 + /// create/rename). Surfaces inline so the modal can stay open on failure
545 + /// rather than discarding the user's typed input (C-3). Cleared on modal
546 + /// open / successful submit / explicit Cancel.
547 + pub name_modal_error: Option<String>,
548 + }
549 +
550 + /// First-run onboarding banners and hints.
551 + #[derive(Default)]
552 + pub struct OnboardingUiState {
553 + pub show_vfs_banner: bool,
554 + /// Show "Right-click for options · F1 for shortcuts" hint until dismissed.
555 + pub show_first_launch_hint: bool,
556 + /// Show the "set up cloud sync to back up your library" banner. Surfaces
557 + /// once after the first successful import; persisted via `sync_intro_dismissed`.
558 + pub show_sync_intro: bool,
559 + }
560 +
376 561 /// GUI state for the sync setup and panel.
377 562 pub struct SyncUiState {
378 563 /// Whether the sync panel overlay is open.
@@ -731,7 +916,9 @@
731 916 }
732 917
733 918 /// Current import/analysis workflow state.
919 + #[derive(Default)]
734 920 pub enum ImportMode {
921 + #[default]
735 922 None,
736 923 ConfigureImport {
737 924 source: PathBuf,
@@ -824,8 +1011,9 @@
824 1011
825 1012 // --- Sort ---
826 1013
827 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
828 1015 pub enum SortColumn {
1016 + #[default]
829 1017 Name,
830 1018 Bpm,
831 1019 Key,
@@ -833,8 +1021,9 @@
833 1021 Classification,
834 1022 }
835 1023
836 - #[derive(Debug, Clone, PartialEq, Eq)]
1024 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
837 1025 pub enum SortDirection {
1026 + #[default]
838 1027 Ascending,
839 1028 Descending,
840 1029 }
@@ -9,7 +9,7 @@
9 9
10 10 /// Draw the detail panel content for the currently selected sample.
11 11 pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
12 - if state.selection.count() > 1 {
12 + if state.nav.selection.count() > 1 {
13 13 draw_multi_summary(ui, state);
14 14 return;
15 15 }
@@ -516,7 +516,7 @@
516 516 // the "click again to undo" trick requires remembering it ran in
517 517 // the first place. Threshold 10 keeps small selections frictionless.
518 518 if selected_count > 10 {
519 - state.pending_confirm = Some(
519 + state.overlay.pending_confirm = Some(
520 520 crate::state::ConfirmAction::ReverseSamples { count: selected_count },
521 521 );
522 522 } else {
@@ -76,7 +76,7 @@
76 76
77 77 /// Draw the export configuration screen.
78 78 pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
79 - let (item_count, profile_count) = match &state.import_mode {
79 + let (item_count, profile_count) = match &state.import_wf.import_mode {
80 80 ImportMode::ConfigureExport {
81 81 items,
82 82 available_profiles,
@@ -89,7 +89,7 @@
89 89 ui.add_space(theme::space::SM);
90 90
91 91 // Warnings
92 - if let ImportMode::ConfigureExport { ref items, ref config, ref available_profiles, .. } = state.import_mode {
92 + if let ImportMode::ConfigureExport { ref items, ref config, ref available_profiles, .. } = state.import_wf.import_mode {
93 93 // AIFF size limit warning (M-4): the 4 GB chunk limit translates
94 94 // to ~124 minutes at the worst-case config (stereo 24-bit 96kHz)
95 95 // and considerably more at smaller depths/rates. Compute the
@@ -194,11 +194,11 @@
194 194
195 195 ui.horizontal(|ui| {
196 196 if ui.button("Cancel").clicked() {
197 - state.import_mode = ImportMode::None;
197 + state.import_wf.import_mode = ImportMode::None;
198 198 }
199 199 if ui.button("Export").clicked()
200 200 && let ImportMode::ConfigureExport { ref items, ref config, .. } =
201 - state.import_mode
201 + state.import_wf.import_mode
202 202 {
203 203 let items = items.clone();
204 204 let config = config.clone();
@@ -231,7 +231,7 @@
231 231 ref mut config,
232 232 ref available_profiles,
233 233 ..
234 - } = state.import_mode
234 + } = state.import_wf.import_mode
235 235 {
236 236 let current_label = config
237 237 .device_profile
@@ -309,7 +309,7 @@
309 309
310 310 // --- Format ---
311 311 let has_profile = matches!(
312 - &state.import_mode,
312 + &state.import_wf.import_mode,
313 313 ImportMode::ConfigureExport {
314 314 config: ExportConfig { device_profile: Some(_), .. },
315 315 ..
@@ -318,7 +318,7 @@
318 318
319 319 if !has_profile {
320 320 ui.label(egui::RichText::new("Format").strong());
321 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
321 + if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode {
322 322 let is_original = config.format == ExportFormat::Original;
323 323 let is_wav = config.format == ExportFormat::Wav;
324 324 let is_aiff = config.format == ExportFormat::Aiff;
@@ -354,7 +354,7 @@
354 354
355 355 // --- Audio encoding options (WAV/AIFF) ---
356 356 let needs_encoding_options = matches!(
357 - &state.import_mode,
357 + &state.import_wf.import_mode,
358 358 ImportMode::ConfigureExport {
359 359 config: ExportConfig {
360 360 format: ExportFormat::Wav | ExportFormat::Aiff,
@@ -365,7 +365,7 @@
365 365 );
366 366
367 367 if needs_encoding_options
368 - && let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
368 + && let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode {
369 369 // Sample rate
370 370 ui.label(egui::RichText::new("Sample Rate").strong());
371 371 let rates: [(Option<u32>, &str); 4] = [
@@ -398,7 +398,7 @@
398 398
399 399 // --- Channels ---
400 400 ui.label(egui::RichText::new("Channels").strong());
401 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
401 + if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode {
402 402 let ch_options: [(ExportChannels, &str); 3] = [
403 403 (ExportChannels::Original, "Original"),
404 404 (ExportChannels::Mono, "Mono"),
@@ -415,7 +415,7 @@
415 415
416 416 // --- Structure ---
417 417 ui.label(egui::RichText::new("Structure").strong());
418 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
418 + if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode {
419 419 if ui
420 420 .radio(!config.flatten, "Preserve tree")
421 421 .clicked()
@@ -434,7 +434,7 @@
434 434 ui.add_space(theme::space::MD);
435 435
436 436 // --- Metadata sidecar ---
437 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
437 + if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode {
438 438 ui.checkbox(
439 439 &mut config.metadata_sidecar,
440 440 "Include metadata (.audiofiles.json)",
@@ -443,7 +443,7 @@
443 443 ui.add_space(theme::space::MD);
444 444
445 445 // --- Naming pattern (when flattened) ---
446 - if let ImportMode::ConfigureExport { ref mut config, ref items, .. } = state.import_mode
446 + if let ImportMode::ConfigureExport { ref mut config, ref items, .. } = state.import_wf.import_mode
447 447 && config.flatten {
448 448 ui.label(egui::RichText::new("Naming Pattern").strong());
449 449 let mut pattern = config.naming_pattern.clone().unwrap_or_default();
@@ -527,7 +527,7 @@
527 527 // Capture the browse click inside the import_mode borrow, then request
528 528 // the dialog after it ends (the async handler re-borrows import_mode).
529 529 let mut browse_from: Option<std::path::PathBuf> = None;
530 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
530 + if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode {
531 531 ui.horizontal(|ui| {
532 532 let dest_display = config.destination.display().to_string();
533 533 ui.label(&dest_display);
@@ -538,7 +538,7 @@
538 538 }
539 539 if let Some(start_dir) = browse_from {
540 540 state.dialogs.pick_folder_in("Export Destination", start_dir, |s, p| {
541 - if let ImportMode::ConfigureExport { config, .. } = &mut s.import_mode {
541 + if let ImportMode::ConfigureExport { config, .. } = &mut s.import_wf.import_mode {
542 542 config.destination = p;
543 543 }
544 544 });
@@ -549,7 +549,7 @@
549 549
550 550 /// Draw the export progress screen.
551 551 pub fn draw_export_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
552 - let (completed, total, current_name) = match &state.import_mode {
552 + let (completed, total, current_name) = match &state.import_wf.import_mode {
553 553 ImportMode::Exporting {
554 554 completed,
555 555 total,
@@ -594,7 +594,7 @@
594 594
595 595 /// Draw the export complete screen with summary and error list.
596 596 pub fn draw_export_complete(ui: &mut egui::Ui, state: &mut BrowserState) {
597 - let (total, error_count) = match &state.import_mode {
597 + let (total, error_count) = match &state.import_wf.import_mode {
598 598 ImportMode::ExportComplete { total, errors } => (*total, errors.len()),
599 599 _ => return,
600 600 };
@@ -611,7 +611,7 @@
611 611 ));
612 612 ui.add_space(theme::space::MD);
613 613
614 - if let ImportMode::ExportComplete { ref errors, .. } = state.import_mode {
614 + if let ImportMode::ExportComplete { ref errors, .. } = state.import_wf.import_mode {
615 615 egui::ScrollArea::vertical()
616 616 .max_height(200.0)
617 617 .show(ui, |ui| {
@@ -640,13 +640,13 @@
640 640 ui.horizontal(|ui| {
641 641 // m-9: primary_button for the anchor moment of the export flow.
642 642 if widgets::primary_button(ui, "Done").clicked() {
643 - state.import_mode = ImportMode::None;
643 + state.import_wf.import_mode = ImportMode::None;
644 644 }
645 645 // p-1: open the destination folder so users can verify the result
646 646 // without navigating Finder/Explorer themselves. Suppressed when
647 647 // the destination wasn't stashed (e.g. export driven from outside
648 648 // the wizard's run_export path).
649 - if let Some(dest) = state.last_export_destination.clone()
649 + if let Some(dest) = state.import_wf.last_export_destination.clone()
650 650 && ui.button("Open destination folder").clicked() {
651 651 #[cfg(target_os = "macos")]
652 652 let _ = std::process::Command::new("open").arg(&dest).spawn();