diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index de26922d29..29081fd81e 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; @@ -1447,6 +1447,15 @@ fn export_adjustments_as_lut( Ok(cube_lut) } +fn record_export_folder(path: &Path, folders: &Mutex>) { + if let Some(parent) = path.parent() { + folders + .lock() + .unwrap() + .insert(parent.to_string_lossy().into_owned()); + } +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn export_images_impl( paths: Vec, @@ -1546,6 +1555,7 @@ pub(crate) async fn export_images_impl( } let used_paths = Arc::new(Mutex::new(std::collections::HashSet::new())); + let output_folders = Arc::new(Mutex::new(BTreeSet::new())); let semaphore = Arc::new(tokio::sync::Semaphore::new(num_threads)); let mut join_handles = Vec::new(); @@ -1570,6 +1580,7 @@ pub(crate) async fn export_images_impl( let cancellation_token_clone = Arc::clone(&cancellation_token); let adjustments_mode = adjustments_mode.clone(); let used_paths_clone = Arc::clone(&used_paths); + let output_folders_clone = Arc::clone(&output_folders); let handle = tokio::task::spawn_blocking(move || { ensure_export_not_cancelled(&cancellation_token_clone)?; @@ -1722,6 +1733,7 @@ pub(crate) async fn export_images_impl( } #[cfg(not(target_os = "android"))] fs::write(&output_path, cube_bytes).map_err(|e| e.to_string())?; + record_export_folder(&output_path, &output_folders_clone); ensure_export_not_cancelled(&cancellation_token_clone)?; return Ok(()); } @@ -1801,6 +1813,7 @@ pub(crate) async fn export_images_impl( &source_path_str, &export_settings, )?; + record_export_folder(&output_path, &output_folders_clone); ensure_export_not_cancelled(&cancellation_token_clone)?; if export_settings.preserve_timestamps { @@ -1865,6 +1878,10 @@ pub(crate) async fn export_images_impl( &export_state.export_task_token, &cancellation_token, |cancelled| { + let folders: Vec<_> = output_folders.lock().unwrap().iter().cloned().collect(); + if !folders.is_empty() { + let _ = app_handle.emit("export-outputs", &folders); + } if cancelled { log::info!("Batch export cancelled and worker cleanup completed"); let _ = app_handle.emit("export-cancelled", ()); @@ -2352,3 +2369,28 @@ pub async fn estimate_export_sizes( Ok(single_image_extrapolated_size * paths.len()) } + +#[cfg(test)] +mod export_folder_tests { + use super::*; + + #[test] + fn deduplicates_written_output_folders() { + let folders = Mutex::new(BTreeSet::new()); + for path in [ + "exports/scene-a/first.tiff", + "exports/scene-a/second.tiff", + "photos/edited.cube", + ] { + record_export_folder(Path::new(path), &folders); + } + assert_eq!( + folders + .into_inner() + .unwrap() + .into_iter() + .collect::>(), + vec!["exports/scene-a", "photos"] + ); + } +} diff --git a/src/hooks/useTauriListeners.ts b/src/hooks/useTauriListeners.ts index f3e8eadbf8..807739dddf 100644 --- a/src/hooks/useTauriListeners.ts +++ b/src/hooks/useTauriListeners.ts @@ -7,6 +7,12 @@ import { useEditorStore } from '../store/useEditorStore'; import { useUIStore } from '../store/useUIStore'; import { useLibraryStore } from '../store/useLibraryStore'; +function normalizeFolderPath(path: string): string { + const isWindowsPath = /^[a-z]:[\\/]/i.test(path) || path.startsWith('\\\\') || path.startsWith('//'); + const normalized = isWindowsPath ? path.replace(/\\/g, '/').toLowerCase() : path; + return normalized.replace(/\/+$/, '') || '/'; +} + interface TauriListenerProps { refreshAllFolderTrees: () => void; handleSelectSubfolder: (path: string, isNewRoot?: boolean, preloadedImages?: any[], expandParents?: boolean) => void; @@ -160,21 +166,35 @@ export function useTauriListeners({ listen('batch-export-progress', (event: any) => { if (isEffectActive) useProcessStore.getState().setExportState({ progress: event.payload }); }), + listen('export-outputs', (event) => { + if (!isEffectActive) return; + const currentPath = useLibraryStore.getState().currentFolderPath; + if ( + currentPath && + event.payload.some((path) => normalizeFolderPath(path) === normalizeFolderPath(currentPath)) + ) { + refs.current.refreshAllFolderTrees(); + refs.current.refreshImageList(); + } + }), listen('export-complete', () => { if (isEffectActive) useProcessStore.getState().setExportState({ status: Status.Success }); }), listen('export-error', (event: any) => { - if (isEffectActive) + if (isEffectActive) { useProcessStore.getState().setExportState({ status: Status.Error, errorMessage: typeof event.payload === 'string' ? event.payload : 'Unknown error', }); + } }), listen('export-cancelling', () => { if (isEffectActive) useProcessStore.getState().setExportState({ status: Status.Cancelling }); }), listen('export-cancelled', () => { - if (isEffectActive) useProcessStore.getState().setExportState({ status: Status.Cancelled }); + if (isEffectActive) { + useProcessStore.getState().setExportState({ status: Status.Cancelled }); + } }), listen('import-start', (event: any) => { if (isEffectActive)