max / audiofiles
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, |