From abd2522e421be4e66c4f3e5dc118fb87b372c8f6 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:47:08 -0600 Subject: [PATCH 1/2] fix(dialog): allow selecting both files and folders in add music picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file picker regression only allowed directory selection because open_add_music_dialog used pick_folders(). On macOS, use NSOpenPanel directly with canChooseFiles + canChooseDirectories for native combined selection. On Linux, fall back to pick_files() (GTK limitation). Also fix a latent bug in scan_paths_to_library where scanning a single file would mark every other library track as deleted — the inventory deletion logic compared the filesystem walk against ALL DB fingerprints instead of scoping to the scan paths. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 4 + app/frontend/js/stores/library.js | 1 - app/frontend/views/footer.html | 1 + crates/mt-tauri/Cargo.toml | 6 + crates/mt-tauri/src/dialog.rs | 141 +++++++++++++++++++----- crates/mt-tauri/src/scanner/commands.rs | 45 +++++++- 6 files changed, 168 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 182c6276..5b45ff6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2787,12 +2787,16 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "dispatch2", "gtk", "lofty", "lru", "md5", "mt-core", "notify-debouncer-full", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parking_lot", "proptest", "r2d2", diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 3b0b5554..7483415f 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -849,7 +849,6 @@ export function createLibraryStore(Alpine) { throw new Error('Tauri not available'); } - // Use Rust command instead of JS plugin API for better reliability const { invoke } = window.__TAURI__.core; const paths = await invoke('open_add_music_dialog'); diff --git a/app/frontend/views/footer.html b/app/frontend/views/footer.html index 8078f920..b2324562 100644 --- a/app/frontend/views/footer.html +++ b/app/frontend/views/footer.html @@ -184,6 +184,7 @@ class="p-1 text-[#8E8E93] hover:text-foreground transition-colors" @click="$store.library.openAddMusicDialog()" title="Add music to library" + data-testid="add-music-btn" > diff --git a/crates/mt-tauri/Cargo.toml b/crates/mt-tauri/Cargo.toml index de386636..d57cbdf3 100644 --- a/crates/mt-tauri/Cargo.toml +++ b/crates/mt-tauri/Cargo.toml @@ -77,6 +77,12 @@ devtools = ["dep:tauri-plugin-devtools", "tauri/devtools"] mcp = ["dep:tauri-plugin-mcp-bridge"] rust-lru-cache = [] # Legacy Rust LRU cache (Zig implementation is default) +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSOpenPanel", "NSSavePanel", "NSApplication", "NSRunningApplication"] } +objc2-foundation = { version = "0.3", features = ["NSArray", "NSString", "NSURL", "NSEnumerator"] } +dispatch2 = "0.3" + [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" diff --git a/crates/mt-tauri/src/dialog.rs b/crates/mt-tauri/src/dialog.rs index d8fc3b91..d0bd0245 100644 --- a/crates/mt-tauri/src/dialog.rs +++ b/crates/mt-tauri/src/dialog.rs @@ -4,21 +4,25 @@ use tokio::sync::oneshot; #[tauri::command] pub async fn open_file_dialog(app: tauri::AppHandle) -> Result, String> { let (tx, rx) = oneshot::channel(); - + app.dialog() .file() .add_filter("Audio Files", &["mp3", "m4a", "flac", "ogg", "wav", "aac", "wma", "opus"]) .add_filter("All Files", &["*"]) .set_title("Select audio files to add to your library") .pick_files(move |paths| { - let result = paths.map(|p| { - p.iter() - .filter_map(|path| path.as_path().map(|p| p.to_string_lossy().to_string())) - .collect::>() - }).unwrap_or_default(); + let result = paths + .map(|p| { + p.iter() + .filter_map(|path| { + path.as_path().map(|p| p.to_string_lossy().to_string()) + }) + .collect::>() + }) + .unwrap_or_default(); let _ = tx.send(result); }); - + let paths = rx.await.map_err(|e| format!("Dialog error: {}", e))?; println!("[dialog] open_file_dialog: {} files selected", paths.len()); Ok(paths) @@ -27,41 +31,124 @@ pub async fn open_file_dialog(app: tauri::AppHandle) -> Result, Stri #[tauri::command] pub async fn open_folder_dialog(app: tauri::AppHandle) -> Result, String> { let (tx, rx) = oneshot::channel(); - + app.dialog() .file() .set_title("Select folders to add to your library") .pick_folders(move |paths| { - let result = paths.map(|p| { - p.iter() - .filter_map(|path| path.as_path().map(|p| p.to_string_lossy().to_string())) - .collect::>() - }).unwrap_or_default(); + let result = paths + .map(|p| { + p.iter() + .filter_map(|path| { + path.as_path().map(|p| p.to_string_lossy().to_string()) + }) + .collect::>() + }) + .unwrap_or_default(); let _ = tx.send(result); }); - + let paths = rx.await.map_err(|e| format!("Dialog error: {}", e))?; println!("[dialog] open_folder_dialog: {} folders selected", paths.len()); Ok(paths) } +/// Open a native file picker that allows selecting both individual files and directories. +/// +/// - macOS: Uses NSOpenPanel with canChooseFiles + canChooseDirectories (native behavior). +/// - Linux: Falls back to file-only selection since GTK does not support combined mode. #[tauri::command] -pub async fn open_add_music_dialog(app: tauri::AppHandle) -> Result, String> { +pub async fn open_add_music_dialog( + #[allow(unused_variables)] app: tauri::AppHandle, +) -> Result, String> { + let paths = open_add_music_dialog_impl(app).await?; + println!( + "[dialog] open_add_music_dialog: {} paths selected: {:?}", + paths.len(), + paths + ); + Ok(paths) +} + +#[cfg(target_os = "macos")] +async fn open_add_music_dialog_impl( + _app: tauri::AppHandle, +) -> Result, String> { + tokio::task::spawn_blocking(open_combined_dialog_macos) + .await + .map_err(|e| format!("Dialog error: {}", e)) +} + +#[cfg(not(target_os = "macos"))] +async fn open_add_music_dialog_impl( + app: tauri::AppHandle, +) -> Result, String> { + // GTK on Linux does not support combined file+folder selection. + // Fall back to file selection with audio filters. let (tx, rx) = oneshot::channel(); - app.dialog() .file() - .set_title("Select folders to add to your library") - .pick_folders(move |paths| { - let result = paths.map(|p| { - p.iter() - .filter_map(|path| path.as_path().map(|p| p.to_string_lossy().to_string())) - .collect::>() - }).unwrap_or_default(); + .add_filter( + "Audio Files", + &["mp3", "m4a", "flac", "ogg", "wav", "aac", "wma", "opus"], + ) + .add_filter("All Files", &["*"]) + .set_title("Select audio files to add to your library") + .pick_files(move |paths| { + let result = paths + .map(|p| { + p.iter() + .filter_map(|path| { + path.as_path().map(|p| p.to_string_lossy().to_string()) + }) + .collect::>() + }) + .unwrap_or_default(); let _ = tx.send(result); }); - - let paths = rx.await.map_err(|e| format!("Dialog error: {}", e))?; - println!("[dialog] open_add_music_dialog: {} paths selected: {:?}", paths.len(), paths); - Ok(paths) + rx.await.map_err(|e| format!("Dialog error: {}", e)) +} + +/// macOS: create an NSOpenPanel that allows selecting both files and directories. +#[cfg(target_os = "macos")] +fn open_combined_dialog_macos() -> Vec { + use objc2::MainThreadMarker; + use objc2_app_kit::NSOpenPanel; + use objc2_foundation::{NSArray, NSString, NSURL}; + use std::sync::mpsc; + + let (tx, rx) = mpsc::channel(); + + // NSOpenPanel.runModal() must execute on the main thread. + dispatch2::DispatchQueue::main().exec_sync(move || { + // Safe: we are on the main thread via dispatch_sync. + let mtm = MainThreadMarker::new().unwrap(); + let panel = NSOpenPanel::openPanel(mtm); + + panel.setCanChooseFiles(true); + panel.setCanChooseDirectories(true); + panel.setAllowsMultipleSelection(true); + let title = NSString::from_str("Add music to library"); + panel.setTitle(Some(&title)); + + let response = panel.runModal(); + let mut paths: Vec = Vec::new(); + + // NSModalResponseOK = 1 + if response == 1 { + let urls: objc2::rc::Retained> = panel.URLs(); + let count = urls.count(); + for i in 0..count { + let url = urls.objectAtIndex(i); + let path_opt: Option> = url.path(); + if let Some(p) = path_opt { + paths.push(format!("{p}")); + } + } + } + + let _ = tx.send(paths); + }); + + rx.recv().unwrap_or_default() } diff --git a/crates/mt-tauri/src/scanner/commands.rs b/crates/mt-tauri/src/scanner/commands.rs index 39a544d7..a1ffc2f7 100644 --- a/crates/mt-tauri/src/scanner/commands.rs +++ b/crates/mt-tauri/src/scanner/commands.rs @@ -60,6 +60,42 @@ impl From<&ScanResult2Phase> for ScanResultResponse { } } +/// Filter DB fingerprints to only include entries within the scan scope. +/// +/// A fingerprint is in-scope if it exactly matches a provided file path, or +/// lives under a provided directory path. This prevents the inventory's +/// deletion logic from treating out-of-scope tracks as deleted. +fn scope_fingerprints_to_paths( + all: &HashMap, + scan_paths: &[String], +) -> HashMap { + use std::path::Path; + + // Separate file paths from directory paths. + let mut dirs: Vec<&str> = Vec::new(); + let mut files: Vec<&str> = Vec::new(); + for p in scan_paths { + let path = Path::new(p); + if path.is_dir() { + dirs.push(p.as_str()); + } else { + files.push(p.as_str()); + } + } + + all.iter() + .filter(|(filepath, _)| { + // Exact file match + files.iter().any(|f| filepath.as_str() == *f) + // Or under one of the scanned directories + || dirs.iter().any(|d| { + filepath.starts_with(d) && filepath.as_bytes().get(d.len()) == Some(&b'/') + }) + }) + .map(|(k, v)| (k.clone(), *v)) + .collect() +} + /// Get fingerprints from the database for comparison fn get_db_fingerprints(db: &Database) -> Result, String> { let conn = db.conn().map_err(|e| e.to_string())?; @@ -94,8 +130,13 @@ pub async fn scan_paths_to_library( let job_id = generate_job_id(); let start_time = Instant::now(); - // Get current fingerprints from DB - let db_fingerprints = get_db_fingerprints(&db)?; + // Get DB fingerprints scoped to the scan paths only. + // Without scoping, scanning a single file would mark every other track in the + // library as "deleted" because the inventory phase only walks the provided paths. + let db_fingerprints = { + let all = get_db_fingerprints(&db)?; + scope_fingerprints_to_paths(&all, &paths) + }; // Create progress callback that emits standardized Tauri events let app_handle = app.clone(); From e0137ff5b0095e33920a061d5c2ef3eedc11f2ea Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:31:17 -0600 Subject: [PATCH 2/2] fix(lint): remove unnecessary async from non-awaiting methods _loadCacheFromSettings uses only synchronous calls. _fetchLibraryData returns a promise without awaiting it. Co-Authored-By: Claude Opus 4.6 --- app/frontend/js/stores/library.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 7483415f..87ce7055 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -141,7 +141,7 @@ export function createLibraryStore(Alpine) { * Load cached section data from persistent settings * Called during init() to show cached data immediately */ - async _loadCacheFromSettings() { + _loadCacheFromSettings() { if (!window.settings?.initialized) { console.log('[library] settings not initialized, skipping cache load'); return false; @@ -210,7 +210,7 @@ export function createLibraryStore(Alpine) { /** * Fetch library data from backend API */ - async _fetchLibraryData() { + _fetchLibraryData() { // Map frontend sort keys to backend column names const sortKeyMap = { default: 'album',