From 855ad68f957353005ba9a7787dda0b60811914c3 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:22:17 -1000 Subject: [PATCH 1/3] Refresh the library and folder tree after an export Exports into a library folder didn't show up in the grid, filmstrip or folder counts until a manual refresh, because the export-complete listener only set the status. Refresh the folder trees and the current folder after an export completes, fails or is cancelled (a partial batch can still have written files), as import-complete already does. Fixes CyberTimon/RapidRAW#1674 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/hooks/useTauriListeners.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/hooks/useTauriListeners.ts b/src/hooks/useTauriListeners.ts index f3e8eadbf8..017217a343 100644 --- a/src/hooks/useTauriListeners.ts +++ b/src/hooks/useTauriListeners.ts @@ -35,6 +35,13 @@ export function useTauriListeners({ useEffect(() => { let isEffectActive = true; + const refreshAfterExport = () => { + refs.current.refreshAllFolderTrees(); + if (useLibraryStore.getState().currentFolderPath) { + refs.current.refreshImageList(); + } + }; + const flushThumbnailBatch = () => { flushHandle.current = null; if (!isEffectActive) return; @@ -161,20 +168,28 @@ export function useTauriListeners({ if (isEffectActive) useProcessStore.getState().setExportState({ progress: event.payload }); }), listen('export-complete', () => { - if (isEffectActive) useProcessStore.getState().setExportState({ status: Status.Success }); + if (isEffectActive) { + useProcessStore.getState().setExportState({ status: Status.Success }); + refreshAfterExport(); + } }), listen('export-error', (event: any) => { - if (isEffectActive) + if (isEffectActive) { useProcessStore.getState().setExportState({ status: Status.Error, errorMessage: typeof event.payload === 'string' ? event.payload : 'Unknown error', }); + refreshAfterExport(); + } }), 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 }); + refreshAfterExport(); + } }), listen('import-start', (event: any) => { if (isEffectActive) From 21274170d6185f84ba05f2d92c0b1f969e16bd67 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:47:18 -1000 Subject: [PATCH 2/3] Refresh only an open export destination on completion Emit the deduplicated output folders from completed image and LUT workers. Refresh the library and folder tree only if the current folder is among those destinations; errors and cancellations no longer force a broad refresh. AI-assisted with Codex. Native fmt, strict Clippy and library tests pass; seven local listener tests pass. Frontend typecheck and scoped ESLint reproduce the unchanged upstream baseline (70 type errors and 34 lint errors). Local only, pending maintainer approval to push. --- src-tauri/src/export_processing.rs | 41 +++++++++++++++++++++++++++--- src/hooks/useTauriListeners.ts | 17 +++++-------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index de26922d29..7e52072050 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -1447,6 +1447,18 @@ fn export_adjustments_as_lut( Ok(cube_lut) } +fn completed_export_folders(results: &[Result]) -> Vec { + let mut folders: Vec<_> = results + .iter() + .filter_map(|result| result.as_ref().ok()) + .filter_map(|path| path.parent()) + .map(|folder| folder.to_string_lossy().into_owned()) + .collect(); + folders.sort(); + folders.dedup(); + folders +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn export_images_impl( paths: Vec, @@ -1697,7 +1709,7 @@ pub(crate) async fn export_images_impl( used.insert(output_path.clone()); } - let result: Result<(), String> = (|| { + let result: Result = (|| { if extension == "cube" { let cube_bytes = export_adjustments_as_lut( &js_adjustments, @@ -1723,7 +1735,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())?; ensure_export_not_cancelled(&cancellation_token_clone)?; - return Ok(()); + return Ok(output_path); } let loaded_image = if is_current_edit { @@ -1823,7 +1835,7 @@ pub(crate) async fn export_images_impl( )?; } - Ok(()) + Ok(output_path) })(); if !cancellation_token_clone.load(Ordering::SeqCst) { @@ -1858,6 +1870,7 @@ pub(crate) async fn export_images_impl( } } + let output_folders = completed_export_folders(&results); let errors: Vec = results.into_iter().filter_map(Result::err).collect(); let error_count = errors.len(); let export_state = app_handle.state::(); @@ -1888,7 +1901,7 @@ pub(crate) async fn export_images_impl( "batch-export-progress", serde_json::json!({ "current": total_paths, "total": total_paths, "path": "" }), ); - let _ = app_handle.emit("export-complete", ()); + let _ = app_handle.emit("export-complete", &output_folders); } }, ); @@ -2352,3 +2365,23 @@ pub async fn estimate_export_sizes( Ok(single_image_extrapolated_size * paths.len()) } + +#[cfg(test)] +mod export_folder_tests { + use super::*; + + #[test] + fn reports_only_completed_destination_folders_once() { + let results = vec![ + Ok(PathBuf::from("exports/scene-a/first.tiff")), + Ok(PathBuf::from("exports/scene-a/second.tiff")), + Err("Export failed".into()), + Ok(PathBuf::from("photos/edited.cube")), + ]; + assert_eq!( + completed_export_folders(&results), + vec!["exports/scene-a", "photos"] + ); + assert!(completed_export_folders(&[Err("Export cancelled".into())]).is_empty()); + } +} diff --git a/src/hooks/useTauriListeners.ts b/src/hooks/useTauriListeners.ts index 017217a343..610aa8af7d 100644 --- a/src/hooks/useTauriListeners.ts +++ b/src/hooks/useTauriListeners.ts @@ -35,13 +35,6 @@ export function useTauriListeners({ useEffect(() => { let isEffectActive = true; - const refreshAfterExport = () => { - refs.current.refreshAllFolderTrees(); - if (useLibraryStore.getState().currentFolderPath) { - refs.current.refreshImageList(); - } - }; - const flushThumbnailBatch = () => { flushHandle.current = null; if (!isEffectActive) return; @@ -167,10 +160,14 @@ export function useTauriListeners({ listen('batch-export-progress', (event: any) => { if (isEffectActive) useProcessStore.getState().setExportState({ progress: event.payload }); }), - listen('export-complete', () => { + listen('export-complete', (event) => { if (isEffectActive) { useProcessStore.getState().setExportState({ status: Status.Success }); - refreshAfterExport(); + const currentPath = useLibraryStore.getState().currentFolderPath; + if (currentPath && event.payload.includes(currentPath)) { + refs.current.refreshAllFolderTrees(); + refs.current.refreshImageList(); + } } }), listen('export-error', (event: any) => { @@ -179,7 +176,6 @@ export function useTauriListeners({ status: Status.Error, errorMessage: typeof event.payload === 'string' ? event.payload : 'Unknown error', }); - refreshAfterExport(); } }), listen('export-cancelling', () => { @@ -188,7 +184,6 @@ export function useTauriListeners({ listen('export-cancelled', () => { if (isEffectActive) { useProcessStore.getState().setExportState({ status: Status.Cancelled }); - refreshAfterExport(); } }), listen('import-start', (event: any) => { From 09ce39947070ce68066fd9aaf3a3da0be3f4ef40 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:52:56 -1000 Subject: [PATCH 3/3] Refresh written export folders for failures and cancellation too Track destination folders immediately after successful file writes. Emit export-outputs before the success, error or cancelled terminal event, while keeping export-complete payload-less. Match only the open folder, normalising trailing separators and Windows slash/case spelling. AI-assisted with Codex. Fmt, locked strict Clippy, library test and 15 local listener tests pass. Frontend typecheck (70 errors) and scoped ESLint (34 errors) match the unchanged upstream baseline. Local only; no upstream push or comment. --- src-tauri/src/export_processing.rs | 59 +++++++++++++++++------------- src/hooks/useTauriListeners.ts | 26 +++++++++---- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 7e52072050..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,16 +1447,13 @@ fn export_adjustments_as_lut( Ok(cube_lut) } -fn completed_export_folders(results: &[Result]) -> Vec { - let mut folders: Vec<_> = results - .iter() - .filter_map(|result| result.as_ref().ok()) - .filter_map(|path| path.parent()) - .map(|folder| folder.to_string_lossy().into_owned()) - .collect(); - folders.sort(); - folders.dedup(); - folders +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)] @@ -1558,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(); @@ -1582,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)?; @@ -1709,7 +1708,7 @@ pub(crate) async fn export_images_impl( used.insert(output_path.clone()); } - let result: Result = (|| { + let result: Result<(), String> = (|| { if extension == "cube" { let cube_bytes = export_adjustments_as_lut( &js_adjustments, @@ -1734,8 +1733,9 @@ 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(output_path); + return Ok(()); } let loaded_image = if is_current_edit { @@ -1813,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 { @@ -1835,7 +1836,7 @@ pub(crate) async fn export_images_impl( )?; } - Ok(output_path) + Ok(()) })(); if !cancellation_token_clone.load(Ordering::SeqCst) { @@ -1870,7 +1871,6 @@ pub(crate) async fn export_images_impl( } } - let output_folders = completed_export_folders(&results); let errors: Vec = results.into_iter().filter_map(Result::err).collect(); let error_count = errors.len(); let export_state = app_handle.state::(); @@ -1878,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", ()); @@ -1901,7 +1905,7 @@ pub(crate) async fn export_images_impl( "batch-export-progress", serde_json::json!({ "current": total_paths, "total": total_paths, "path": "" }), ); - let _ = app_handle.emit("export-complete", &output_folders); + let _ = app_handle.emit("export-complete", ()); } }, ); @@ -2371,17 +2375,22 @@ mod export_folder_tests { use super::*; #[test] - fn reports_only_completed_destination_folders_once() { - let results = vec![ - Ok(PathBuf::from("exports/scene-a/first.tiff")), - Ok(PathBuf::from("exports/scene-a/second.tiff")), - Err("Export failed".into()), - Ok(PathBuf::from("photos/edited.cube")), - ]; + 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!( - completed_export_folders(&results), + folders + .into_inner() + .unwrap() + .into_iter() + .collect::>(), vec!["exports/scene-a", "photos"] ); - assert!(completed_export_folders(&[Err("Export cancelled".into())]).is_empty()); } } diff --git a/src/hooks/useTauriListeners.ts b/src/hooks/useTauriListeners.ts index 610aa8af7d..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,16 +166,20 @@ export function useTauriListeners({ listen('batch-export-progress', (event: any) => { if (isEffectActive) useProcessStore.getState().setExportState({ progress: event.payload }); }), - listen('export-complete', (event) => { - if (isEffectActive) { - useProcessStore.getState().setExportState({ status: Status.Success }); - const currentPath = useLibraryStore.getState().currentFolderPath; - if (currentPath && event.payload.includes(currentPath)) { - refs.current.refreshAllFolderTrees(); - refs.current.refreshImageList(); - } + 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) { useProcessStore.getState().setExportState({