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
Commit: 3f0916fb9bf20dc2b2b3d077dd1f79bd2ec4f75d
Parent: 3e902f1
26 files changed, +1013 insertions, -962 deletions
@@ -823,7 +823,7 @@ impl AudioFilesApp {
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 @@ impl AudioFilesApp {
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 @@ pub fn draw_browser(
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 @@ pub fn draw_browser(
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 @@ pub fn draw_browser(
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 @@ fn draw_normal_browser(
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ use super::*;
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
946 946
947 947 /// Apply user-entered tags to each imported folder's samples, then start analysis.
Lines truncated
@@ -16,18 +16,18 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ use audiofiles_core::analysis::waveform::WaveformData;
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 @@ pub struct BrowserState {
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 @@ pub struct BrowserState {
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 @@ pub struct BrowserState {
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>,
284 -
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>,
213 + // Import / analysis workflow
214 + pub import_wf: ImportWorkflowUiState,
215 +
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 @@ pub struct BrowserState {
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 @@ pub struct BrowserState {
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 @@ pub struct BrowserState {
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 @@ pub struct BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ fn icase_cmp(a: &str, b: &str) -> std::cmp::Ordering {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ fn insert_fake_sample(state: &BrowserState, hash: &str) {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod bulk_ops {
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 @@ mod export_flow {
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 @@ mod export_flow {
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 @@ mod export_flow {
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 @@ mod export_flow {
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 @@ mod export_flow {
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 @@ mod export_flow {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ mod import_and_analysis {
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 @@ pub struct RuleDraft {
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 @@ impl ReviewSort {
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 @@ pub struct SuggestionState {
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 @@ pub enum SortColumn {
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 @@ use super::widgets;
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 @@ fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ fn bytes_per_sec_for_config(config: &ExportConfig) -> u64 {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_export_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_export_complete(ui: &mut egui::Ui, state: &mut BrowserState) {
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 @@ pub fn draw_export_complete(ui: &mut egui::Ui, state: &mut BrowserState) {
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();
@@ -40,8 +40,8 @@ pub fn draw_file_list(
40 40 // Empty state: no contents, at root level, no active search.
41 41 // The first-run guided onboarding has a custom layout (numbered steps with
42 42 // an inline Import link); other empty states route through `empty_state`.
43 - if state.contents.is_empty() && state.current_dir.is_none() && state.search_query.is_empty() && !state.search_filter.is_active() {
44 - if state.show_first_launch_hint {
43 + if state.nav.contents.is_empty() && state.nav.current_dir.is_none() && state.search.search_query.is_empty() && !state.search.search_filter.is_active() {
44 + if state.onboarding.show_first_launch_hint {
45 45 ui.vertical_centered(|ui| {
46 46 ui.add_space(ui.available_height() * 0.08);
47 47 ui.label(
@@ -124,9 +124,9 @@ pub fn draw_file_list(
124 124 }
125 125
126 126 // Empty state: filters active but no results in this folder
127 - if state.contents.is_empty() && (state.search_filter.is_active() || !state.search_query.is_empty()) {
128 - let filter_count = state.search_filter.active_count();
129 - let hint = if filter_count > 0 && !state.search_query.is_empty() {
127 + if state.nav.contents.is_empty() && (state.search.search_filter.is_active() || !state.search.search_query.is_empty()) {
128 + let filter_count = state.search.search_filter.active_count();
129 + let hint = if filter_count > 0 && !state.search.search_query.is_empty() {
130 130 format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" })
131 131 } else if filter_count > 0 {
132 132 format!("{} filter{} active \u{2014} try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" })
@@ -142,8 +142,8 @@ pub fn draw_file_list(
142 142 Some(&hint),
143 143 Some(widgets::EmptyStateCta { label: "Clear search and filters", tooltip: None }),
144 144 ) {
145 - state.search_filter.clear();
146 - state.search_query.clear();
145 + state.search.search_filter.clear();
146 + state.search.search_query.clear();
147 147 state.apply_search();
148 148 }
149 149 return;
@@ -152,7 +152,7 @@ pub fn draw_file_list(
152 152 // Sync first-touch banner: surfaces once after the first import. Suppressed
153 153 // while the welcome hint is up (user hasn't imported yet) and dismissed
154 154 // permanently once the user clicks either button.
155 - if state.show_sync_intro && !state.show_first_launch_hint {
155 + if state.onboarding.show_sync_intro && !state.onboarding.show_first_launch_hint {
156 156 widgets::info_banner(
157 157 ui,
158 158 "Your library is local. Set up cloud sync to back it up and use it on other devices.",
@@ -170,8 +170,8 @@ pub fn draw_file_list(
170 170 }
171 171
172 172 let row_height = state.row_height;
173 - let has_parent = state.current_dir.is_some();
174 - let contents = state.contents.clone();
173 + let has_parent = state.nav.current_dir.is_some();
174 + let contents = state.nav.contents.clone();
175 175 let offset = if has_parent { 1usize } else { 0 };
176 176 let col_cfg = &state.column_config;
177 177
@@ -184,7 +184,7 @@ pub fn draw_file_list(
184 184 .column(Column::remainder().at_least(120.0)); // Name (includes icon)
185 185
186 186 // Scroll to focused row when keyboard navigation requests it.
187 - if let Some(row) = state.scroll_to_row.take() {
187 + if let Some(row) = state.nav.scroll_to_row.take() {
188 188 table = table.scroll_to_row(row, None);
189 189 }
190 190
@@ -219,13 +219,13 @@ pub fn draw_file_list(
219 219 let show_tags = col_cfg.show_tags;
220 220
221 221 // Snapshot sort state so the header closure doesn't borrow `state` mutably.
222 - let sort_col = state.sort_column;
223 - let sort_dir = state.sort_direction.clone();
222 + let sort_col = state.nav.sort_column;
223 + let sort_dir = state.nav.sort_direction.clone();
224 224 // While similarity / duplicate search is active, results come back sorted
225 225 // by similarity score — letting the user click a column header to "sort
226 226 // by name" silently in that view would scramble the ranking. Disable
227 227 // header clicks instead so the score order stays trustworthy.
228 - let sort_enabled = state.similarity_search_hash.is_none();
228 + let sort_enabled = state.search.similarity_search_hash.is_none();
229 229 let clicked_col = std::cell::Cell::new(None::<SortColumn>);
230 230
231 231 table
@@ -286,7 +286,7 @@ pub fn draw_file_list(
286 286 body.rows(row_height, parent_rows + contents.len(), |mut row| {
287 287 let display_idx = row.index();
288 288 if has_parent && display_idx == 0 {
289 - let selected = state.selection.contains(0);
289 + let selected = state.nav.selection.contains(0);
290 290 row.set_selected(selected);
291 291 row.col(|ui| {
292 292 // Parent ".." entry: render muted so it reads as
@@ -315,7 +315,7 @@ pub fn draw_file_list(
315 315 let i = display_idx - parent_rows;
316 316 let node = &contents[i];
317 317 let row_idx = i + offset;
318 - let selected = state.selection.contains(row_idx);
318 + let selected = state.nav.selection.contains(row_idx);
319 319 row.set_selected(selected);
320 320
321 321 // Name (with inline icon)
@@ -394,7 +394,7 @@ pub fn draw_file_list(
394 394 let bg_resp = ui.interact(remaining, ui.id().with("file_list_bg"), egui::Sense::click());
395 395 // Click on empty space clears selection.
396 396 if bg_resp.clicked() {
397 - state.selection.clear();
397 + state.nav.selection.clear();
398 398 state.refresh_selected_tags();
399 399 state.refresh_selected_detail();
400 400 }
@@ -411,13 +411,13 @@ fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) {
411 411
412 412 if modifiers.command {
413 413 // Cmd/Ctrl+Click: toggle item
414 - state.selection.toggle(row_idx);
414 + state.nav.selection.toggle(row_idx);
415 415 } else if modifiers.shift {
416 416 // Shift+Click: range select
417 - state.selection.extend_to(row_idx, len);
417 + state.nav.selection.extend_to(row_idx, len);
418 418 } else {
419 419 // Plain click: single select
420 - state.selection.set_single(row_idx);
420 + state.nav.selection.set_single(row_idx);
421 421 state.autoplay_current();
422 422 }
423 423
@@ -485,7 +485,7 @@ fn draw_name_column(
485 485 if resp.double_clicked() {
486 486 match node.node.node_type {
487 487 NodeType::Directory => {
488 - state.selection.set_single(row_idx);
488 + state.nav.selection.set_single(row_idx);
489 489 state.enter_directory();
490 490 }
491 491 NodeType::Sample => {
@@ -516,8 +516,8 @@ fn draw_name_column(
516 516 && resp.dragged()
517 517 && resp.drag_delta().length() > 4.0
518 518 {
519 - if !state.selection.contains(row_idx) {
520 - state.selection.set_single(row_idx);
519 + if !state.nav.selection.contains(row_idx) {
520 + state.nav.selection.set_single(row_idx);
521 521 }
522 522 start_os_drag(state);
523 523 }
@@ -542,11 +542,11 @@ fn draw_name_column(
542 542 // selection collapses the selection to just that row first (matches Finder /
543 543 // Explorer / Nautilus convention), so the menu's actions clearly target the
544 544 // row under the cursor instead of an out-of-frame multi-selection.
545 - if resp.secondary_clicked() && !state.selection.contains(row_idx) {
546 - state.selection.set_single(row_idx);
545 + if resp.secondary_clicked() && !state.nav.selection.contains(row_idx) {
546 + state.nav.selection.set_single(row_idx);
547 547 }
548 548 resp.context_menu(|ui| {
549 - if state.selection.count() > 1 && state.selection.contains(row_idx) {
549 + if state.nav.selection.count() > 1 && state.nav.selection.contains(row_idx) {
550 550 draw_multi_context_menu(ui, state);
551 551 } else {
552 552 draw_context_menu(ui, state, row_idx, node, sync_manager);
@@ -112,8 +112,8 @@ pub fn draw_context_menu(
112 112 // Add to Collection submenu
113 113 if let Some(hash) = &node.node.sample_hash {
114 114 let hash_clone = hash.clone();
115 - let collections = state.collections.clone();
116 - let is_in_collection = state.active_collection.is_some();
115 + let collections = state.collections_ui.collections.clone();
116 + let is_in_collection = state.collections_ui.active_collection.is_some();
117 117 if !collections.is_empty() {
118 118 ui.menu_button("Add to Collection", |ui| {
119 119 for coll in &collections {
@@ -127,7 +127,7 @@ pub fn draw_context_menu(
127 127 });
128 128 }
129 129 if is_in_collection
130 - && let Some(active_id) = state.active_collection
130 + && let Some(active_id) = state.collections_ui.active_collection
131 131 && widgets::danger_button(ui, "Remove from Collection").clicked() {
132 132 let _ = state.backend.remove_from_collection(active_id, &hash_clone);
133 133 state.refresh_collections();
@@ -155,7 +155,7 @@ pub fn draw_context_menu(
155 155 ui.close();
156 156 }
157 157 if ui.button("Export...").clicked() {
158 - state.selection.set_single(row_idx);
158 + state.nav.selection.set_single(row_idx);
159 159 state.start_export_flow(Some(vec![node.node.id]));
160 160 ui.close();
161 161 }
@@ -174,7 +174,7 @@ pub fn draw_context_menu(
174 174 || node.musical_key.is_some()
175 175 || node.classification.is_some();
176 176 if has_existing {
177 - state.pending_confirm =
177 + state.overlay.pending_confirm =
178 178 Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
179 179 sample_hashes: hashes,
180 180 overwrite_count: 1,
@@ -188,34 +188,34 @@ pub fn draw_context_menu(
188 188 }
189 189 ui.separator();
190 190 if widgets::danger_button(ui, "Delete").clicked() {
191 - state.selection.set_single(row_idx);
191 + state.nav.selection.set_single(row_idx);
192 192 state.confirm_delete_selected();
193 193 ui.close();
194 194 }
195 195 }
196 196 NodeType::Directory => {
197 197 if ui.button("Open").clicked() {
198 - state.selection.set_single(row_idx);
198 + state.nav.selection.set_single(row_idx);
199 199 state.enter_directory();
200 200 ui.close();
201 201 }
202 202 if ui.button("New Folder").clicked() {
203 - state.show_dir_create = true;
204 - state.dir_create_input.clear();
203 + state.vfs_modal.show_dir_create = true;
204 + state.vfs_modal.dir_create_input.clear();
205 205 ui.close();
206 206 }
207 207 if ui.button("Rename").clicked() {
208 - state.dir_rename_target = Some((node.node.id, node.node.name.clone()));
208 + state.vfs_modal.dir_rename_target = Some((node.node.id, node.node.name.clone()));
209 209 ui.close();
210 210 }
211 211 if ui.button("Export...").clicked() {
212 - state.selection.set_single(row_idx);
212 + state.nav.selection.set_single(row_idx);
213 213 state.start_export_flow(Some(vec![node.node.id]));
214 214 ui.close();
215 215 }
216 216 ui.separator();
217 217 if widgets::danger_button(ui, "Delete").clicked() {
218 - state.selection.set_single(row_idx);
218 + state.nav.selection.set_single(row_idx);
219 219 state.confirm_delete_selected();
220 220 ui.close();
221 221 }
@@ -225,7 +225,7 @@ pub fn draw_context_menu(
225 225
226 226 /// Context menu when multiple items are selected.
227 227 pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
228 - let count = state.selection.count();
228 + let count = state.nav.selection.count();
229 229 ui.label(egui::RichText::new(format!("{count} items selected")).strong());
230 230 ui.separator();
231 231
@@ -259,7 +259,7 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
259 259 }
260 260
261 261 // Add to Collection submenu (bulk)
262 - let collections = state.collections.clone();
262 + let collections = state.collections_ui.collections.clone();
263 263 if !collections.is_empty() {
264 264 ui.menu_button("Add to Collection", |ui| {
265 265 for coll in &collections {
@@ -290,7 +290,7 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
290 290 }
291 291
292 292 // Remove from Collection (when viewing a collection)
293 - if let Some(active_id) = state.active_collection
293 + if let Some(active_id) = state.collections_ui.active_collection
294 294 && widgets::danger_button(ui, "Remove from Collection").clicked() {
295 295 let nodes = state.selected_nodes();
296 296 for n in &nodes {
@@ -323,7 +323,7 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
323 323 .filter(|n| n.bpm.is_some() || n.musical_key.is_some() || n.classification.is_some())
324 324 .count();
325 325 if overwrite_count > 0 {
326 - state.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
326 + state.overlay.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
327 327 sample_hashes: hashes,
328 328 overwrite_count,
329 329 });
@@ -407,8 +407,8 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
407 407 /// Context menu for right-clicking empty space in the file list.
408 408 pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
409 409 if ui.button("New Folder").clicked() {
410 - state.show_dir_create = true;
411 - state.dir_create_input.clear();
410 + state.vfs_modal.show_dir_create = true;
411 + state.vfs_modal.dir_create_input.clear();
412 412 ui.close();
413 413 }
414 414 if ui.button("Import files...").clicked() {
@@ -430,11 +430,11 @@ pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState)
430 430 state.dialogs.pick_folder("Import folder", |s, p| s.show_import_options(p));
431 431 ui.close();
432 432 }
433 - if state.selection.count() > 0 {
433 + if state.nav.selection.count() > 0 {
434 434 ui.separator();
435 - let label = format!("Deselect ({}) (Esc)", state.selection.count());
435 + let label = format!("Deselect ({}) (Esc)", state.nav.selection.count());
436 436 if ui.button(label).clicked() {
437 - state.selection.clear();
437 + state.nav.selection.clear();
438 438 state.refresh_selected_tags();
439 439 state.refresh_selected_detail();
440 440 ui.close();
@@ -44,97 +44,97 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
44 44 // plausible-BPM filter. M-1 closes the sentinel-visibility gap via the
45 45 // per-section [clear] link; M-2 snaps the sibling when min crosses max so
46 46 // contradictory states (returning zero rows by construction) can't be set.
47 - let bpm_active = state.search_filter.bpm_min.is_some() || state.search_filter.bpm_max.is_some();
47 + let bpm_active = state.search.search_filter.bpm_min.is_some() || state.search.search_filter.bpm_max.is_some();
48 48 widgets::filter_section(ui, "BPM Range", bpm_active, |ui| {
49 49 if bpm_active && draw_section_clear(ui) {
50 - state.search_filter.bpm_min = None;
51 - state.search_filter.bpm_max = None;
50 + state.search.search_filter.bpm_min = None;
51 + state.search.search_filter.bpm_max = None;
52 52 changed = true;
53 53 }
54 54 ui.horizontal(|ui| {
55 - let mut min = state.search_filter.bpm_min.unwrap_or(0.0);
56 - let mut max = state.search_filter.bpm_max.unwrap_or(300.0);
55 + let mut min = state.search.search_filter.bpm_min.unwrap_or(0.0);
56 + let mut max = state.search.search_filter.bpm_max.unwrap_or(300.0);
57 57 ui.label("Min");
58 58 let r = ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0));
59 59 if r.changed() {
60 60 if min > max { max = min; }
61 - state.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
62 - state.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
61 + state.search.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
62 + state.search.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
63 63 }
64 64 if requery_now(&r) { changed = true; }
65 65 ui.label("Max");
66 66 let r = ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0));
67 67 if r.changed() {
68 68 if max < min { min = max; }
69 - state.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
70 - state.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
69 + state.search.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
70 + state.search.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
71 71 }
72 72 if requery_now(&r) { changed = true; }
73 73 });
74 74 });
75 75
76 - let dur_active = state.search_filter.duration_min.is_some() || state.search_filter.duration_max.is_some();
76 + let dur_active = state.search.search_filter.duration_min.is_some() || state.search.search_filter.duration_max.is_some();
77 77 widgets::filter_section(ui, "Duration (s)", dur_active, |ui| {
78 78 if dur_active && draw_section_clear(ui) {
79 - state.search_filter.duration_min = None;
80 - state.search_filter.duration_max = None;
79 + state.search.search_filter.duration_min = None;
80 + state.search.search_filter.duration_max = None;
81 81 changed = true;
82 82 }
83 83 ui.horizontal(|ui| {
84 - let mut min = state.search_filter.duration_min.unwrap_or(0.0);
85 - let mut max = state.search_filter.duration_max.unwrap_or(600.0);
84 + let mut min = state.search.search_filter.duration_min.unwrap_or(0.0);
85 + let mut max = state.search.search_filter.duration_max.unwrap_or(600.0);
86 86 ui.label("Min");
87 87 let r = ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0));
88 88 if r.changed() {
89 89 if min > max { max = min; }
90 - state.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
91 - state.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
90 + state.search.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
91 + state.search.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
92 92 }
93 93 if requery_now(&r) { changed = true; }
94 94 ui.label("Max");
95 95 let r = ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0));
96 96 if r.changed() {
97 97 if max < min { min = max; }
98 - state.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
99 - state.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
98 + state.search.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
99 + state.search.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
100 100 }
101 101 if requery_now(&r) { changed = true; }
102 102 });
103 103 });
104 104
105 - let loud_active = state.search_filter.peak_db_min.is_some() || state.search_filter.peak_db_max.is_some();
105 + let loud_active = state.search.search_filter.peak_db_min.is_some() || state.search.search_filter.peak_db_max.is_some();
106 106 widgets::filter_section(ui, "Loudness (dB)", loud_active, |ui| {
107 107 if loud_active && draw_section_clear(ui) {
108 - state.search_filter.peak_db_min = None;
109 - state.search_filter.peak_db_max = None;
108 + state.search.search_filter.peak_db_min = None;
109 + state.search.search_filter.peak_db_max = None;
110 110 changed = true;
111 111 }
112 112 ui.horizontal(|ui| {
113 - let mut min = state.search_filter.peak_db_min.unwrap_or(-96.0);
114 - let mut max = state.search_filter.peak_db_max.unwrap_or(0.0);
113 + let mut min = state.search.search_filter.peak_db_min.unwrap_or(-96.0);
114 + let mut max = state.search.search_filter.peak_db_max.unwrap_or(0.0);
115 115 ui.label("Min");
116 116 let r = ui.add(egui::DragValue::new(&mut min).speed(0.5).range(-96.0..=0.0).suffix(" dB"));
117 117 if r.changed() {
118 118 if min > max { max = min; }
119 - state.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
120 - state.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
119 + state.search.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
120 + state.search.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
121 121 }
122 122 if requery_now(&r) { changed = true; }
123 123 ui.label("Max");
124 124 let r = ui.add(egui::DragValue::new(&mut max).speed(0.5).range(-96.0..=0.0).suffix(" dB"));
125 125 if r.changed() {
126 126 if max < min { min = max; }
127 - state.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
128 - state.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
127 + state.search.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
128 + state.search.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
129 129 }
130 130 if requery_now(&r) { changed = true; }
131 131 });
132 132 });
133 133
134 - let class_active = !state.search_filter.classifications.is_empty();
134 + let class_active = !state.search.search_filter.classifications.is_empty();
135 135 widgets::filter_section(ui, "Classification", class_active, |ui| {
136 136 if class_active && draw_section_clear(ui) {
137 - state.search_filter.classifications.clear();
137 + state.search.search_filter.classifications.clear();
138 138 changed = true;
139 139 }
140 140 // Grouped: drums (percussive) | tonal (pitched) | other. The hard-coded
@@ -150,14 +150,14 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
150 150 ui.label(egui::RichText::new(*label).small().color(theme::content_muted()));
151 151 ui.horizontal_wrapped(|ui| {
152 152 for class in *classes {
153 - let active = state.search_filter.classifications.contains(&class.to_string());
153 + let active = state.search.search_filter.classifications.contains(&class.to_string());
154 154 let color = theme::classification_color(class);
155 155 let rt = egui::RichText::new(*class).color(color);
156 156 if ui.selectable_label(active, rt).clicked() {
157 157 if active {
158 - state.search_filter.classifications.retain(|c| c != *class);
158 + state.search.search_filter.classifications.retain(|c| c != *class);
159 159 } else {
160 - state.search_filter.classifications.push(class.to_string());
160 + state.search.search_filter.classifications.push(class.to_string());
161 161 }
162 162 changed = true;
163 163 }
@@ -166,22 +166,22 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
166 166 }
167 167 });
168 168
169 - let key_active = !state.search_filter.keys.is_empty();
169 + let key_active = !state.search.search_filter.keys.is_empty();
170 170 widgets::filter_section(ui, "Key Filter", key_active, |ui| {
171 171 if key_active && draw_section_clear(ui) {
172 - state.search_filter.keys.clear();
172 + state.search.search_filter.keys.clear();
173 173 changed = true;
174 174 }
175 175 use audiofiles_core::search::KeyFilterMode;
176 176 if let Some(mode) = widgets::toggle_pills(
177 177 ui,
178 - &state.search_filter.key_mode,
178 + &state.search.search_filter.key_mode,
179 179 &[
180 180 (KeyFilterMode::Exact, "Exact", "Match only selected keys"),
181 181 (KeyFilterMode::Compatible, "Compatible", "Include musically compatible keys (circle of fifths)"),
182 182 ],
183 183 ) {
184 - state.search_filter.key_mode = mode;
184 + state.search.search_filter.key_mode = mode;
185 185 changed = true;
186 186 }
187 187 ui.add_space(theme::space::SM);
@@ -205,12 +205,12 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
205 205 ui.horizontal_wrapped(|ui| {
206 206 for key in *keys {
207 207 let note = key.split(' ').next().unwrap_or(key);
208 - let active = state.search_filter.keys.contains(&key.to_string());
208 + let active = state.search.search_filter.keys.contains(&key.to_string());
209 209 if ui.selectable_label(active, note).clicked() {
210 210 if active {
211 - state.search_filter.keys.retain(|k| k != *key);
211 + state.search.search_filter.keys.retain(|k| k != *key);
212 212 } else {
213 - state.search_filter.keys.push(key.to_string());
213 + state.search.search_filter.keys.push(key.to_string());
214 214 }
215 215 changed = true;
216 216 }
@@ -223,15 +223,15 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
223 223 // ui.label to filter_section so visual contract matches its siblings (m-3),
224 224 // and gains an entry path so users can add tag filters without round-trip
225 225 // through the detail panel or right-click (M-5).
226 - let tag_active = !state.search_filter.required_tags.is_empty();
226 + let tag_active = !state.search.search_filter.required_tags.is_empty();
227 227 widgets::filter_section(ui, "Tags", tag_active, |ui| {
228 228 if tag_active && draw_section_clear(ui) {
229 - state.search_filter.required_tags.clear();
229 + state.search.search_filter.required_tags.clear();
230 230 changed = true;
231 231 }
232 232 ui.horizontal(|ui| {
233 233 let resp = ui.add(
234 - egui::TextEdit::singleline(&mut state.filter_tag_input)
234 + egui::TextEdit::singleline(&mut state.search.filter_tag_input)
235 235 .hint_text("Filter by tag")
236 236 .desired_width(ui.available_width() - 32.0),
237 237 );
@@ -239,26 +239,26 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
239 239 && ui.input(|i| i.key_pressed(egui::Key::Enter)))
240 240 || ui.small_button("+").on_hover_text("Add tag filter").clicked();
241 241 if commit {
242 - let tag = state.filter_tag_input.trim().to_string();
242 + let tag = state.search.filter_tag_input.trim().to_string();
243 243 if !tag.is_empty() {
244 244 if audiofiles_core::tags::validate_tag(&tag).is_ok() {
245 - if !state.search_filter.required_tags.contains(&tag) {
246 - state.search_filter.required_tags.push(tag);
245 + if !state.search.search_filter.required_tags.contains(&tag) {
246 + state.search.search_filter.required_tags.push(tag);
247 247 changed = true;
248 248 }
249 - state.filter_tag_input.clear();
249 + state.search.filter_tag_input.clear();
250 250 } else {
251 251 state.status = format!("Invalid tag: {tag}");
252 252 }
253 253 }
254 254 }
255 255 });
256 - let tags = state.search_filter.required_tags.clone();
256 + let tags = state.search.search_filter.required_tags.clone();
257 257 for tag in &tags {
258 258 ui.horizontal(|ui| {
259 259 ui.label(egui::RichText::new(tag).color(theme::action()));
260 260 if ui.small_button("x").on_hover_text("Remove tag filter").clicked() {
261 - state.search_filter.required_tags.retain(|t| t != tag);
261 + state.search.search_filter.required_tags.retain(|t| t != tag);
262 262 changed = true;
263 263 }
264 264 });
@@ -272,8 +272,8 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
272 272 .on_hover_text("Reset every filter category and the search query")
273 273 .clicked()
274 274 {
275 - state.search_filter.clear();
276 - state.search_query.clear();
275 + state.search.search_filter.clear();
276 + state.search.search_query.clear();
277 277 changed = true;
278 278 }
279 279
@@ -281,7 +281,7 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
281 281 // the filter panel is covering the table (p-2). Files only — directories
282 282 // are structural and not what the filter targets.
283 283 let file_count = state
284 - .contents
284 + .nav.contents
285 285 .iter()
286 286 .filter(|c| c.node.node_type != audiofiles_core::vfs::NodeType::Directory)
287 287 .count();
@@ -295,25 +295,25 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
295 295 .color(theme::content_muted()),
296 296 );
297 297
298 - if state.search_filter.is_active() {
298 + if state.search.search_filter.is_active() {
299 299 ui.add_space(theme::space::MD);
300 300 ui.separator();
301 301 widgets::subsection_label(ui, "Save as Collection");
302 302 ui.label(egui::RichText::new("Save current filters as a dynamic collection").small().color(theme::content_muted()));
303 303 ui.add_space(theme::space::SM);
304 304 ui.horizontal(|ui| {
305 - let auto_name = state.search_filter.describe();
306 - let edit = egui::TextEdit::singleline(&mut state.collection_filter_name_input)
305 + let auto_name = state.search.search_filter.describe();
306 + let edit = egui::TextEdit::singleline(&mut state.collections_ui.collection_filter_name_input)
307 307 .hint_text(&auto_name)
308 308 .desired_width(ui.available_width() - 50.0);
309 309 let resp = ui.add(edit);
310 - let trimmed = state.collection_filter_name_input.trim();
310 + let trimmed = state.collections_ui.collection_filter_name_input.trim();
311 311 let name = if trimmed.is_empty() { auto_name } else { trimmed.to_string() };
312 312 let enter_commit = resp.lost_focus()
313 313 && ui.input(|i| i.key_pressed(egui::Key::Enter));
314 314 if enter_commit || ui.button("Save").clicked() {
315 315 state.save_dynamic_collection(&name);
316 - state.collection_filter_name_input.clear();
316 + state.collections_ui.collection_filter_name_input.clear();
317 317 }
318 318 });
319 319 }
@@ -76,7 +76,7 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt
76 76 if let Some(ref hash) = state.previewing_hash {
77 77 // Find name from contents
78 78 let name = state
79 - .contents
79 + .nav.contents
80 80 .iter()
81 81 .find(|n| n.node.sample_hash.as_deref() == Some(hash))
82 82 .map(|n| n.node.name.as_str())
@@ -141,7 +141,7 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt
141 141 }
142 142
143 143 // Selection count — on narrow, deferred to the second row.
144 - let sel_count = state.selection.count();
144 + let sel_count = state.nav.selection.count();
145 145 if !narrow && sel_count > 1 {
146 146 dot(ui);
147 147 ui.label(
@@ -206,7 +206,7 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt
206 206 ui.ctx().request_repaint_after(next_threshold);
207 207 }
208 208 }
209 - } else if state.show_first_launch_hint {
209 + } else if state.onboarding.show_first_launch_hint {
210 210 // Clear stamp once the message has gone away so the next post
211 211 // starts a fresh timer.
212 212 state.status_set_at = None;
@@ -238,7 +238,7 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt
238 238 // Wrapped so a very narrow window flows them onto multiple sub-rows
239 239 // instead of clipping.
240 240 ui.horizontal_wrapped(|ui| {
241 - let sel_count = state.selection.count();
241 + let sel_count = state.nav.selection.count();
242 242 if sel_count > 1 {
243 243 ui.label(
244 244 egui::RichText::new(format!("{sel_count} selected"))
@@ -284,7 +284,7 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt
284 284 /// (`dot(ui)`) when the section follows other content.
285 285 fn draw_analysis_coverage(ui: &mut egui::Ui, state: &BrowserState) {
286 286 let total_samples = state
287 - .contents
287 + .nav.contents
288 288 .iter()
289 289 .filter(|n| n.node.sample_hash.is_some())
290 290 .count();
@@ -292,12 +292,12 @@ fn draw_analysis_coverage(ui: &mut egui::Ui, state: &BrowserState) {
292 292 return;
293 293 }
294 294 let analyzed = state
295 - .contents
295 + .nav.contents
296 296 .iter()
297 297 .filter(|n| n.node.sample_hash.is_some() && n.duration.is_some())
298 298 .count();
299 299 let untagged = state
300 - .contents
300 + .nav.contents
301 301 .iter()
302 302 .filter(|n| n.node.sample_hash.is_some() && n.tags.is_empty())
303 303 .count();
@@ -7,7 +7,7 @@ use crate::state::{BrowserState, ImportMode};
7 7
8 8 /// Draw the import configuration screen with strategy radio buttons.
9 9 pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
10 - let (source_display, file_count) = match &state.import_mode {
10 + let (source_display, file_count) = match &state.import_wf.import_mode {
11 11 ImportMode::ConfigureImport { source, audio_file_count, .. } => {
12 12 (source.display().to_string(), *audio_file_count)
13 13 }
@@ -59,23 +59,23 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
59 59 ui.add_space(theme::space::SM);
60 60
61 61 let is_flat = matches!(
62 - &state.import_mode,
62 + &state.import_wf.import_mode,
63 63 ImportMode::ConfigureImport { strategy: ImportStrategy::Flat { .. }, .. }
64 64 );
65 65 let is_new_vfs = matches!(
66 - &state.import_mode,
66 + &state.import_wf.import_mode,
67 67 ImportMode::ConfigureImport { strategy: ImportStrategy::NewVfs { .. }, .. }
68 68 );
69 69 let is_merge = matches!(
70 - &state.import_mode,
70 + &state.import_wf.import_mode,
71 71 ImportMode::ConfigureImport { strategy: ImportStrategy::MergeIntoVfs { .. }, .. }
72 72 );
73 73
74 74 let current_vfs_id = state.current_vfs_id();
75 - let current_dir = state.current_dir;
75 + let current_dir = state.nav.current_dir;
76 76 if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat
77 77 && let (ImportMode::ConfigureImport { strategy, .. }, Some(vfs_id)) =
78 - (&mut state.import_mode, current_vfs_id)
78 + (&mut state.import_wf.import_mode, current_vfs_id)
79 79 {
80 80 *strategy = ImportStrategy::Flat {
81 81 vfs_id,
@@ -88,7 +88,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
88 88 ref mut strategy,
89 89 ref new_vfs_name,
90 90 ..
91 - } = state.import_mode
91 + } = state.import_wf.import_mode
92 92 {
93 93 *strategy = ImportStrategy::NewVfs {
94 94 vfs_name: new_vfs_name.clone(),
@@ -103,7 +103,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
103 103 ref mut new_vfs_name,
104 104 ref mut strategy,
105 105 ..
106 - } = state.import_mode
106 + } = state.import_wf.import_mode
107 107 && ui.text_edit_singleline(new_vfs_name).changed() {
108 108 *strategy = ImportStrategy::NewVfs {
109 109 vfs_name: new_vfs_name.clone(),
@@ -119,7 +119,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
119 119 ref available_vfs,
120 120 selected_merge_vfs_idx,
121 121 ..
122 - } = state.import_mode
122 + } = state.import_wf.import_mode
123 123 && let Some(vfs) = available_vfs.get(selected_merge_vfs_idx)
124 124 {
125 125 *strategy = ImportStrategy::MergeIntoVfs {
@@ -135,7 +135,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
135 135 ref available_vfs,
136 136 ref mut selected_merge_vfs_idx,
137 137 ..
138 - } = state.import_mode
138 + } = state.import_wf.import_mode
139 139 {
140 140 let current_name = available_vfs
141 141 .get(*selected_merge_vfs_idx)
@@ -182,7 +182,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
182 182 // needs a non-empty vault name; MergeIntoVfs needs at least one vault
183 183 // available (the indexed access on available_vfs would otherwise panic
184 184 // if the user reached this screen with no vaults).
185 - let (can_import, disabled_reason) = match &state.import_mode {
185 + let (can_import, disabled_reason) = match &state.import_wf.import_mode {
186 186 ImportMode::ConfigureImport { strategy, new_vfs_name, available_vfs, .. } => {
187 187 match strategy {
188 188 ImportStrategy::Flat { .. } => (true, ""),
@@ -207,7 +207,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
207 207
208 208 ui.horizontal(|ui| {
209 209 if ui.button("Cancel").clicked() {
210 - state.import_mode = ImportMode::None;
210 + state.import_wf.import_mode = ImportMode::None;
211 211 }
212 212 let import_btn = ui.add_enabled(can_import, egui::Button::new("Import"));
213 213 let import_btn = if !can_import && !disabled_reason.is_empty() {
@@ -223,7 +223,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
223 223 ref available_vfs,
224 224 selected_merge_vfs_idx,
225 225 ..
226 - } = state.import_mode
226 + } = state.import_wf.import_mode
227 227 {
228 228 let source = source.clone();
229 229 let strategy = match strat {
@@ -252,7 +252,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
252 252
253 253 /// Draw the analysis configuration screen.
254 254 pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
255 - let (sample_count, mut config) = match &state.import_mode {
255 + let (sample_count, mut config) = match &state.import_wf.import_mode {
256 256 ImportMode::ConfigureAnalysis {
257 257 sample_hashes,
258 258 config,
@@ -304,7 +304,7 @@ pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
304 304 // tags applied on the first pass don't double up if the user
305 305 // commits again. Disabled when nothing's stashed (e.g. reached
306 306 // here outside the folder-import flow).
307 - let can_back = state.last_folder_tags.is_some();
307 + let can_back = state.import_wf.last_folder_tags.is_some();
308 308 if ui
309 309 .add_enabled(can_back, egui::Button::new("Back"))
310 310 .on_hover_text("Return to the folder tagging step")
@@ -314,7 +314,7 @@ pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
314 314 return;
315 315 }
316 316 if ui.button("Run Analysis").clicked() {
317 - let hashes = match &state.import_mode {
317 + let hashes = match &state.import_wf.import_mode {
318 318 ImportMode::ConfigureAnalysis { sample_hashes, .. } => {
319 319 sample_hashes.clone()
320 320 }
@@ -325,7 +325,7 @@ pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
325 325 }
326 326
327 327 if ui.button("Skip analysis").clicked() {
328 - state.import_mode = ImportMode::None;
328 + state.import_wf.import_mode = ImportMode::None;
329 329 state.status = "Imported. Run analysis from the sidebar when ready.".to_string();
330 330 }
331 331 });
@@ -334,7 +334,7 @@ pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
334 334 if let ImportMode::ConfigureAnalysis {
335 335 config: ref mut cfg,
336 336 ..
337 - } = state.import_mode
337 + } = state.import_wf.import_mode
338 338 {
339 339 *cfg = config;
340 340 }
@@ -61,7 +61,7 @@ fn draw_error_log(
61 61 }
62 62
63 63 /// Render the rate + ETA readout below a progress bar (M-11). Reads from the
64 - /// rolling sample buffer on `state.operation_progress`; silently suppresses
64 + /// rolling sample buffer on `state.import_wf.operation_progress`; silently suppresses
65 65 /// itself until the buffer has enough data to predict.
66 66 fn draw_rate_and_eta(
67 67 ui: &mut egui::Ui,
@@ -70,7 +70,7 @@ fn draw_rate_and_eta(
70 70 total: usize,
71 71 noun_per_sec: &str,
72 72 ) {
73 - if let Some(progress) = state.operation_progress.as_mut() {
73 + if let Some(progress) = state.import_wf.operation_progress.as_mut() {
74 74 progress.record(completed);
75 75 let parts: Vec<String> = [
76 76 progress.rate().map(|r| format!("{r:.1} {noun_per_sec}")),
@@ -92,7 +92,7 @@ fn draw_rate_and_eta(
92 92 /// Draw the folder import progress screen.
93 93 pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
94 94 let ctx = ui.ctx().clone();
95 - let (total, completed, current_name, walking, walking_count, total_bytes, loose_files) = match &state.import_mode {
95 + let (total, completed, current_name, walking, walking_count, total_bytes, loose_files) = match &state.import_wf.import_mode {
96 96 ImportMode::Importing {
97 97 total,
98 98 completed,
@@ -172,11 +172,11 @@ pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
172 172 // behind a click (M-1). Hide toggle at the top-right.
173 173 draw_error_log(
174 174 ui,
175 - &mut state.import_errors_expanded,
176 - &state.import_file_errors,
177 - &state.analysis_errors,
175 + &mut state.import_wf.import_errors_expanded,
176 + &state.import_wf.import_file_errors,
177 + &state.import_wf.analysis_errors,
178 178 );
179 - let err_count = state.import_file_errors.len() + state.analysis_errors.len();
179 + let err_count = state.import_wf.import_file_errors.len() + state.import_wf.analysis_errors.len();
180 180
181 181 ui.add_space(theme::space::SECTION);
182 182 ui.horizontal(|ui| {
@@ -209,7 +209,7 @@ pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
209 209 /// Draw the cleanup (orphaned sample removal) progress screen.
210 210 pub fn draw_cleanup_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
211 211 let ctx = ui.ctx().clone();
212 - let (completed, total, current_name) = match &state.import_mode {
212 + let (completed, total, current_name) = match &state.import_wf.import_mode {
213 213 ImportMode::Cleaning {
214 214 completed,
215 215 total,
@@ -253,7 +253,7 @@ pub fn draw_cleanup_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
253 253 /// Draw the analysis progress screen.
254 254 pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
255 255 let ctx = ui.ctx().clone();
256 - let (completed, total, current_name) = match &state.import_mode {
256 + let (completed, total, current_name) = match &state.import_wf.import_mode {
257 257 ImportMode::Analyzing {
258 258 completed,
259 259 total,
@@ -290,11 +290,11 @@ pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
290 290 // Error log (M-1).
291 291 draw_error_log(
292 292 ui,
293 - &mut state.import_errors_expanded,
294 - &state.import_file_errors,
295 - &state.analysis_errors,
293 + &mut state.import_wf.import_errors_expanded,
294 + &state.import_wf.import_file_errors,
295 + &state.import_wf.analysis_errors,
296 296 );
297 - let err_count = state.import_file_errors.len() + state.analysis_errors.len();
297 + let err_count = state.import_wf.import_file_errors.len() + state.import_wf.analysis_errors.len();
298 298
299 299 ui.add_space(theme::space::SECTION);
300 300 ui.horizontal(|ui| {
@@ -318,7 +318,7 @@ pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
318 318 /// analysis, or export. Phase-5 C-3: cancelling shouldn't drop straight to
319 319 /// `None` — the user needs to know what landed vs what was discarded.
320 320 pub fn draw_operation_cancelled(ui: &mut egui::Ui, state: &mut BrowserState) {
321 - let (kind, completed, total, destination) = match &state.import_mode {
321 + let (kind, completed, total, destination) = match &state.import_wf.import_mode {
322 322 ImportMode::OperationCancelled {
323 323 kind, completed, total, destination,
324 324 } => (*kind, *completed, *total, destination.clone()),
@@ -366,7 +366,7 @@ pub fn draw_operation_cancelled(ui: &mut egui::Ui, state: &mut BrowserState) {
366 366
367 367 ui.add_space(theme::space::SECTION);
368 368 if widgets::primary_button(ui, "Done").clicked() {
369 - state.import_mode = ImportMode::None;
369 + state.import_wf.import_mode = ImportMode::None;
370 370 }
371 371 });
372 372 }
@@ -6,7 +6,7 @@ use super::super::{theme, widgets};
6 6
7 7 /// Draw the post-import error review screen.
8 8 pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
9 - if !matches!(state.import_mode, ImportMode::ReviewErrors) {
9 + if !matches!(state.import_wf.import_mode, ImportMode::ReviewErrors) {
10 10 return;
11 11 }
12 12
@@ -14,8 +14,8 @@ pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
14 14 ui.heading("Import Summary");
15 15 ui.add_space(theme::space::LG);
16 16
17 - let analysis_count = state.analysis_errors.len();
18 - let import_count = state.import_file_errors.len();
17 + let analysis_count = state.import_wf.analysis_errors.len();
18 + let import_count = state.import_wf.import_file_errors.len();
19 19
20 20 // Analysis errors: files in the store that couldn't be analyzed.
21 21 // M-13: explanatory copy distinguishes this category from the
@@ -44,7 +44,7 @@ pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
44 44 .id_salt("analysis_errors")
45 45 .max_height(200.0)
46 46 .show(ui, |ui| {
47 - for (i, err) in state.analysis_errors.iter().enumerate() {
47 + for (i, err) in state.import_wf.analysis_errors.iter().enumerate() {
48 48 ui.horizontal(|ui| {
49 49 // Row labels rendered in primary text. The section
50 50 // heading carries the red emphasis (M-13); per-row
@@ -66,7 +66,7 @@ pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
66 66 // Route per-row Remove through the confirm dialog rather than
67 67 // deleting on click (C-2). Detail line names the specific file.
68 68 if let Some((idx, name)) = remove_request {
69 - state.pending_confirm = Some(ConfirmAction::RemoveFailedSamples {
69 + state.overlay.pending_confirm = Some(ConfirmAction::RemoveFailedSamples {
70 70 single_index: Some(idx),
71 71 count: 1,
72 72 name: Some(name),
@@ -100,7 +100,7 @@ pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
100 100 .id_salt("import_errors")
101 101 .max_height(200.0)
102 102 .show(ui, |ui| {
103 - for err in &state.import_file_errors {
103 + for err in &state.import_wf.import_file_errors {
104 104 ui.horizontal(|ui| {
105 105 ui.label(&err.path);
106 106 ui.label(
@@ -123,7 +123,7 @@ pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
123 123 // detail line surfaces the count so the user can see the blast
124 124 // radius before committing to permanent deletion.
125 125 if analysis_count > 0 && widgets::danger_button(ui, "Remove All Failed").clicked() {
126 - state.pending_confirm = Some(ConfirmAction::RemoveFailedSamples {
126 + state.overlay.pending_confirm = Some(ConfirmAction::RemoveFailedSamples {
127 127 single_index: None,
128 128 count: analysis_count,
129 129 name: None,