//! Library state: smart folders, collections, similarity search, refresh helpers, mirror. use super::*; impl BrowserState { // --- Misc helpers --- /// Absolute filesystem path to the focused sample, or `None` if no sample is selected. pub fn selected_sample_path(&self) -> Option { let node = self.selected_node()?; let hash = node.node.sample_hash.as_ref()?; let path = self.resolve_sample_path(hash).ok()?; Some(path.to_string_lossy().into_owned()) } /// Reload the VFS list from the database and reset navigation to root. pub fn refresh_vfs_list(&mut self) { match self.backend.list_vfs() { Ok(list) => self.vfs_list = Arc::new(list), Err(e) => { error!("Failed to refresh VFS list: {e}"); return; } } if self.current_vfs_idx >= self.vfs_list.len() { self.current_vfs_idx = 0; } self.current_dir = None; self.breadcrumb.clear(); self.selection.clear(); self.refresh_contents(); self.refresh_collections(); self.check_loose_files_integrity(); } /// Run an integrity check for loose-files mode vaults. Updates `loose_files_missing_count` /// and shows the warning overlay if any source files are missing. pub fn check_loose_files_integrity(&mut self) { if !self.settings.is_loose_files { self.loose_files_missing_count = 0; return; } match self.backend.check_vault_integrity() { Ok((_valid, missing)) => { self.loose_files_missing_count = missing; if missing > 0 { self.show_loose_files_warning = true; } } Err(e) => { warn!("Loose-files integrity check failed: {e}"); } } } /// Purge all loose-files mode samples whose source files are missing. /// Refreshes the VFS listing afterward. pub fn purge_missing_loose_files(&mut self) { match self.backend.purge_missing_loose_files() { Ok(purged) => { self.status = format!("Purged {purged} missing samples"); self.loose_files_missing_count = 0; self.show_loose_files_warning = false; self.refresh_contents(); } Err(e) => { self.status = format!("Purge failed: {e}"); } } } /// Dismiss the loose-files integrity warning without purging. pub fn dismiss_loose_files_warning(&mut self) { self.show_loose_files_warning = false; } /// Locate missing loose-files mode samples by walking `search_root` and /// hash-verifying candidates. The dialog stays open with an updated /// `loose_files_missing_count` so the user can run Locate again against a /// different directory if some samples are still missing. pub fn locate_missing_loose_files(&mut self, search_root: std::path::PathBuf) { match self.backend.relocate_missing_loose_files(&search_root) { Ok((relocated, still_missing)) => { self.loose_files_missing_count = still_missing; self.status = if relocated == 0 { "No matching files found in that folder.".to_string() } else if still_missing == 0 { format!("Relocated {relocated} samples. All missing files found.") } else { format!( "Relocated {relocated} samples. {still_missing} still missing.", ) }; if still_missing == 0 { self.show_loose_files_warning = false; } self.refresh_contents(); } Err(e) => { self.status = format!("Could not locate sample on disk \u{2014} {e}"); } } } /// User-initiated local orphan cleanup. Wraps the backend call's /// trigger suppression so the cleanup doesn't push DELETE samples /// rows over sync to other devices. Returns silently on the empty /// case; on success surfaces a count in the status line. pub fn cleanup_orphans_now(&mut self) { match self.backend.cleanup_orphans_local() { Ok(0) => self.status = "No orphaned samples to clean up.".to_string(), Ok(n) => { self.status = format!( "Removed {n} orphaned sample{}.", if n == 1 { "" } else { "s" } ); self.refresh_contents(); } Err(e) => self.status = format!("Cleanup failed: {e}"), } } /// Dismiss the first-launch hint and persist the preference. pub fn dismiss_first_launch_hint(&mut self) { self.show_first_launch_hint = false; let _ = self.backend.set_config("hints_dismissed", "1"); } /// Re-surface the welcome screen for a user who dismissed it. Persists the /// reset so the welcome renders again on next launch until re-dismissed. pub fn show_welcome(&mut self) { self.show_first_launch_hint = true; let _ = self.backend.set_config("hints_dismissed", "0"); } /// Dismiss the sync-intro banner and persist the preference. Called both /// from the banner's "Maybe later" button and (implicitly) when the user /// clicks "Set up sync" — the banner should not re-appear after either. pub fn dismiss_sync_intro(&mut self) { self.show_sync_intro = false; let _ = self.backend.set_config("sync_intro_dismissed", "1"); } /// Whether the user has work in progress that would be interrupted by a /// library switch. Used to gate the library picker with a confirm modal so /// an accidental click doesn't cancel an active import or bulk operation. pub fn has_in_flight_work(&self) -> bool { !matches!(self.import_mode, crate::state::ImportMode::None) || self.bulk_modal.is_some() || self.pending_import_preflight.is_some() } /// Re-surface the VFS first-run banner ("a vault is your sample collection…"). /// Used from Settings / Help to bring back onboarding context after dismissal. pub fn reset_vfs_explanation(&mut self) { self.show_vfs_banner = true; let _ = self.backend.set_config("vfs_explained", "0"); } /// Globally rename a tag: every sample that carries `old_tag` will instead /// carry `new_tag`. Used by the tag context menu in the sidebar. Refreshes /// the cached tag list and posts a status message with the affected count. pub fn rename_tag_globally(&mut self, old_tag: &str, new_tag: &str) { match self.backend.rename_tag_globally(old_tag, new_tag) { Ok(count) => { self.refresh_all_tags(); // Also retire the old name from any active filter so the user // isn't filtering on a tag that no longer exists. self.search_filter.required_tags.retain(|t| t != old_tag); self.apply_search(); self.status = format!("Renamed tag: {old_tag} → {new_tag} ({count} sample{})", if count == 1 { "" } else { "s" }); } Err(e) => self.status = format!("Rename failed: {e}"), } } /// Refresh the cached list of all tags. pub fn refresh_all_tags(&mut self) { self.all_tags = Arc::new(self.backend.list_all_tags().unwrap_or_else(|e| { warn!("Failed to refresh tags: {e}"); Vec::new() })); } /// Save the current search filter as a dynamic collection with the given name. pub fn save_dynamic_collection(&mut self, name: &str) { match self.backend.create_dynamic_collection(name, &self.search_filter) { Ok(_) => { self.status = format!("Saved collection: {name}"); } Err(e) => { self.status = format!("Failed to save collection: {e}"); } } self.refresh_collections(); } /// Activate a dynamic collection: apply its filter and refresh. pub fn activate_dynamic_collection(&mut self, _id: CollectionId, filter: &SearchFilter) { self.active_collection = None; // not a manual-collection view self.search_filter = filter.clone(); self.search_query = filter.text_query.clone(); self.similarity_search_hash = None; self.similarity_source_name = None; self.selection.clear(); self.refresh_contents(); } // --- Collections --- /// Refresh the collection list from the database. pub fn refresh_collections(&mut self) { self.collections = self.backend.list_collections() .unwrap_or_else(|e| { warn!("Failed to load collections: {e}"); Vec::new() }); } /// Activate a collection: show its members in the file list. pub fn activate_collection(&mut self, id: CollectionId) { let hashes = match self.backend.list_collection_members(id) { Ok(h) => h, Err(e) => { self.status = format!("Failed to load collection: {e}"); return; } }; let Some(vfs_id) = self.current_vfs_id() else { return }; let hash_refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hash_refs) .unwrap_or_default(); let count = nodes.len(); self.contents = Arc::new(nodes); self.active_collection = Some(id); self.similarity_search_hash = None; self.similarity_source_name = None; self.selection.clear(); self.status = format!("{count} samples in collection"); } /// Deactivate collection view and return to normal browsing. pub fn deactivate_collection(&mut self) { self.active_collection = None; self.refresh_contents(); } // --- Similarity search --- /// Find samples similar to the given hash and display them. pub fn find_similar(&mut self, hash: &str) { match self.backend.find_similar(hash, 50) { Ok(results) => { let Some(vfs_id) = self.current_vfs_id() else { return }; let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect(); let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes) .unwrap_or_default(); let count = nodes.len(); self.contents = Arc::new(nodes); self.similarity_search_hash = Some(hash.to_string()); self.similarity_source_name = self.backend.sample_original_name(hash).ok(); self.selection.clear(); self.status = format!("Found {count} similar samples"); } Err(e) => { self.status = format!("Similarity search failed: {e}"); } } } /// Find near-duplicates of the given sample by comparing peak envelope fingerprints. pub fn find_near_duplicates(&mut self, hash: &str) { match self.backend.find_near_duplicates(hash, 50) { Ok(results) => { let Some(vfs_id) = self.current_vfs_id() else { return }; let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect(); let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes) .unwrap_or_default(); let count = nodes.len(); self.contents = Arc::new(nodes); self.similarity_search_hash = Some(hash.to_string()); self.similarity_source_name = self.backend.sample_original_name(hash).ok(); self.selection.clear(); self.status = format!("Found {count} near-duplicates"); } Err(e) => { self.status = format!("Duplicate search failed: {e}"); } } } /// Clear similarity search mode and return to normal browsing. pub fn clear_similarity_search(&mut self) { self.similarity_search_hash = None; self.similarity_source_name = None; self.similarity_source_name = None; self.refresh_contents(); } // --- Column config --- /// Load column config from the user_config table. pub fn load_column_config(&mut self) { if let Ok(Some(json)) = self.backend.get_config("column_config") && let Ok(parsed) = serde_json::from_str::(&json) { self.column_config = parsed; } } /// Save the current theme ID to the user_config table. pub fn save_theme_preference(&self) { let _ = self.backend.set_config("theme", &self.current_theme_id); } /// Reset column visibility, sort, and row density to defaults. The Settings /// → Appearance "Reset columns" button calls this — a column accidentally /// dragged to 5 px stays that wide forever in egui memory until the app /// restarts, so the tooltip notes that widths recover on next launch. pub fn reset_columns(&mut self) { self.column_config = ColumnConfig::default(); self.row_height = 24.0; self.sort_column = SortColumn::Name; self.sort_direction = SortDirection::Ascending; self.save_column_config(); let _ = self.backend.set_config("row_height", "24"); self.status = "Columns reset to defaults".to_string(); } /// Invert the current selection across the visible rows, then strip the /// ".." parent entry (it's not a sample and must never participate in a /// bulk operation). Used by Cmd+Shift+I and the matching menu items. pub fn invert_selection(&mut self) { let len = self.visible_len(); self.selection.invert(len); if self.current_dir.is_some() { self.selection.selected.remove(&0); if self.selection.focus == 0 && len > 1 { self.selection.focus = 1; } if self.selection.anchor == 0 && len > 1 { self.selection.anchor = 1; } } self.refresh_selected_tags(); self.refresh_selected_detail(); } /// Persist the per-classification dismissed-suggestion map. fn save_dismissed_suggestions(&self) { // unwrap is safe: HashMap> serialises cleanly. let json = serde_json::to_string(&self.dismissed_suggestions).unwrap(); let _ = self.backend.set_config("suggestions.dismissed", &json); } /// Mark a tag suggestion as rejected for the given classification so it /// stops appearing on every future sample of that class. pub fn dismiss_suggestion(&mut self, classification: &str, tag: &str) { let entry = self .dismissed_suggestions .entry(classification.to_string()) .or_default(); if !entry.iter().any(|t| t == tag) { entry.push(tag.to_string()); self.save_dismissed_suggestions(); // M-1: remember the most recent dismissal so the detail panel can // surface an inline Undo for ~5 seconds. Replaces the previous // last-dismissed marker; older dismissals are no longer reachable // via the inline affordance (they're still in `dismissed_suggestions` // and recoverable via Settings → Reset suggestions). self.last_dismissed_suggestion = Some(( classification.to_string(), tag.to_string(), std::time::Instant::now(), )); } } /// Re-enable the most recently dismissed suggestion (M-1). Clears the /// inline-Undo marker on success or when the entry is gone (already cleared /// by Settings → Reset suggestions, for example). pub fn undo_last_dismissal(&mut self) { let Some((class, tag, _)) = self.last_dismissed_suggestion.take() else { return; }; if let Some(entry) = self.dismissed_suggestions.get_mut(&class) { entry.retain(|t| t != &tag); if entry.is_empty() { self.dismissed_suggestions.remove(&class); } self.save_dismissed_suggestions(); self.status = format!("Restored suggestion: {tag}"); } } /// Clear every dismissed suggestion across all classifications. Surfaced /// from Settings → Reset suggestions so a user who has over-dismissed /// during exploration can start fresh. pub fn reset_dismissed_suggestions(&mut self) { let n: usize = self.dismissed_suggestions.values().map(|v| v.len()).sum(); self.dismissed_suggestions.clear(); self.save_dismissed_suggestions(); self.status = format!("Reset {n} dismissed suggestion{}", if n == 1 { "" } else { "s" }); } /// Save column config to the user_config table. pub fn save_column_config(&self) { // unwrap is safe: ColumnConfig contains only primitive fields (bools, enums) // with derived Serialize impls, so serialisation cannot fail. let json = serde_json::to_string(&self.column_config).unwrap(); let _ = self.backend.set_config("column_config", &json); } // --- VFS Mirror --- /// Mark the VFS mirror as needing a re-sync. pub fn mark_mirror_dirty(&mut self) { if self.mirror_enabled { self.mirror_dirty = true; } } /// Run a mirror sync if the dirty flag is set. Returns true if a sync ran. pub fn sync_mirror_if_dirty(&mut self) -> bool { if !self.mirror_enabled || !self.mirror_dirty { return false; } self.mirror_dirty = false; match self.backend.sync_vfs_mirror(&self.mirror_path) { Ok((dirs, links, removed)) => { if dirs + links + removed > 0 { tracing::debug!(dirs, links, removed, "Mirror synced"); } true } Err(e) => { tracing::warn!("Mirror sync failed: {e}"); false } } } /// Enable or disable the VFS mirror. Persists the setting. pub fn set_mirror_enabled(&mut self, enabled: bool) { self.mirror_enabled = enabled; let _ = self .backend .set_config("mirror_enabled", if enabled { "1" } else { "0" }); if enabled { // Run initial sync immediately. self.mirror_dirty = true; self.sync_mirror_if_dirty(); } else { // Remove the mirror directory. let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path); } } /// Set the mirror path. Persists the setting. pub fn set_mirror_path(&mut self, path: PathBuf) { // If mirror is enabled, remove old mirror before switching. if self.mirror_enabled { let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path); } self.mirror_path = path; let _ = self .backend .set_config("mirror_path", &self.mirror_path.to_string_lossy()); if self.mirror_enabled { self.mirror_dirty = true; } } }