diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 46f3737b4..050c26917 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -105,3 +105,6 @@ opt-level = 3 codegen-units = 1 lto = true strip = true + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] } diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 83ebfddf0..d394945e9 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -147,7 +147,7 @@ pub struct AppState { pub gpu_processor: Mutex>, pub ai_state: Mutex>, pub ai_init_lock: TokioMutex<()>, - pub export_task_handle: Mutex>>, + pub export_task_token: Arc>>>, pub hdr_result: Arc>>, pub panorama_result: Arc>>, pub denoise_result: Arc>>, diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index 6c03dd6d2..e450286e7 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, GenericImageView, GrayImage, ImageBuffer, ImageFormat, Luma, imageops}; @@ -273,6 +273,130 @@ fn apply_export_resize_and_watermark( Ok(image) } +fn ensure_export_not_cancelled(cancellation_token: &AtomicBool) -> Result<(), String> { + if cancellation_token.load(Ordering::SeqCst) { + Err("Export cancelled".to_string()) + } else { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExportCancellationRequest { + Requested, + AlreadyRequested, + NoActiveTask, +} + +struct ExportTaskGuard { + task_token: Arc>>>, + cancellation_token: Arc, + app_handle: Option, +} + +impl ExportTaskGuard { + fn new( + task_token: Arc>>>, + cancellation_token: Arc, + ) -> Self { + Self { + task_token, + cancellation_token, + app_handle: None, + } + } + + fn with_app_handle( + task_token: Arc>>>, + cancellation_token: Arc, + app_handle: tauri::AppHandle, + ) -> Self { + let mut guard = Self::new(task_token, cancellation_token); + guard.app_handle = Some(app_handle); + guard + } +} + +fn register_export_task( + task_token: &Mutex>>, +) -> Result, String> { + let mut active_token = task_token.lock().unwrap(); + if active_token.is_some() { + return Err("An export is already in progress.".to_string()); + } + + let cancellation_token = Arc::new(AtomicBool::new(false)); + *active_token = Some(Arc::clone(&cancellation_token)); + Ok(cancellation_token) +} + +fn request_export_cancellation( + task_token: &Mutex>>, + on_requested: F, +) -> ExportCancellationRequest +where + F: FnOnce(), +{ + let active_token = task_token.lock().unwrap(); + let Some(cancellation_token) = active_token.as_ref() else { + return ExportCancellationRequest::NoActiveTask; + }; + + if cancellation_token.swap(true, Ordering::SeqCst) { + ExportCancellationRequest::AlreadyRequested + } else { + // Keep the task slot locked until the terminal event is emitted so a new + // export cannot receive a late cancellation event from the previous one. + on_requested(); + ExportCancellationRequest::Requested + } +} + +fn finish_export_task( + task_token: &Mutex>>, + cancellation_token: &Arc, + on_finish: F, +) -> bool +where + F: FnOnce(bool), +{ + let mut active_token = task_token.lock().unwrap(); + let Some(current_token) = active_token.as_ref() else { + return false; + }; + if !Arc::ptr_eq(current_token, cancellation_token) { + return false; + } + + let cancelled = cancellation_token.load(Ordering::SeqCst); + *active_token = None; + + // Keep the mutex held while notifying the UI. The task slot is logically + // free, but a new export cannot register until the terminal event has + // been serialized, preventing the old event from racing with new UI state. + on_finish(cancelled); + true +} + +impl Drop for ExportTaskGuard { + fn drop(&mut self) { + let app_handle = self.app_handle.clone(); + let _ = finish_export_task( + &self.task_token, + &self.cancellation_token, + |cancelled| match (cancelled, app_handle) { + (true, Some(app_handle)) => { + let _ = app_handle.emit("export-cancelled", ()); + } + (false, Some(app_handle)) => { + let _ = app_handle.emit("export-error", "Export task terminated unexpectedly"); + } + _ => {} + }, + ); + } +} + #[allow(clippy::too_many_arguments)] fn process_image_for_export_pipeline( path: &str, @@ -542,9 +666,12 @@ fn export_masks_for_image( state: &tauri::State, is_raw: bool, app_handle: &tauri::AppHandle, + cancellation_token: &AtomicBool, ) -> Result<(), String> { + ensure_export_not_cancelled(cancellation_token)?; let (transformed_image, unscaled_crop_offset) = apply_all_transformations(Cow::Borrowed(base_image), js_adjustments); + ensure_export_not_cancelled(cancellation_token)?; let (img_w, img_h) = transformed_image.dimensions(); let mask_definitions: Vec = js_adjustments .get("masks") @@ -552,19 +679,21 @@ fn export_masks_for_image( .unwrap_or_default(); let warped_image = resolve_warped_image_for_masks(state, js_adjustments, &mask_definitions); - let mask_bitmaps: Vec, Vec>> = mask_definitions - .iter() - .filter_map(|def| { - generate_mask_bitmap( - def, - img_w, - img_h, - 1.0, - unscaled_crop_offset, - warped_image.as_deref(), - ) - }) - .collect(); + let mut mask_bitmaps = Vec::with_capacity(mask_definitions.len()); + for definition in &mask_definitions { + ensure_export_not_cancelled(cancellation_token)?; + if let Some(bitmap) = generate_mask_bitmap( + definition, + img_w, + img_h, + 1.0, + unscaled_crop_offset, + warped_image.as_deref(), + ) { + mask_bitmaps.push(bitmap); + } + ensure_export_not_cancelled(cancellation_token)?; + } if !mask_bitmaps.is_empty() { let tm_override = resolve_tonemapper_override_from_handle(app_handle, is_raw); @@ -583,6 +712,7 @@ fn export_masks_for_image( .unwrap_or("jpg"); for (i, _) in mask_bitmaps.iter().enumerate() { + ensure_export_not_cancelled(cancellation_token)?; let single_adjustments = build_single_mask_adjustments(&all_adjustments, i); let full_white_mask = ImageBuffer::from_fn(img_w, img_h, |_, _| Luma([255u8])); let single_bitmaps: Vec, Vec>> = vec![full_white_mask]; @@ -600,6 +730,7 @@ fn export_masks_for_image( }, "export_mask_image", )?; + ensure_export_not_cancelled(cancellation_token)?; let with_options = apply_export_resize_and_watermark(processed, export_settings)?; let (out_w, out_h) = with_options.dimensions(); @@ -610,6 +741,7 @@ fn export_masks_for_image( out_h, imageops::FilterType::Lanczos3, ); + ensure_export_not_cancelled(cancellation_token)?; let mask_image_path = output_dir.join(format!("{}_mask_{}_image.{}", stem, i, extension)); @@ -621,12 +753,14 @@ fn export_masks_for_image( source_path_str, export_settings, )?; + ensure_export_not_cancelled(cancellation_token)?; if export_settings.preserve_timestamps { set_timestamps_from_exif(Path::new(source_path_str), &mask_image_path); } let alpha_bytes = encode_grayscale_to_png(&alpha_resized)?; + ensure_export_not_cancelled(cancellation_token)?; #[cfg(target_os = "android")] { let file_name = mask_alpha_path @@ -642,6 +776,7 @@ fn export_masks_for_image( #[cfg(not(target_os = "android"))] fs::write(&mask_alpha_path, alpha_bytes).map_err(|e| e.to_string())?; + ensure_export_not_cancelled(cancellation_token)?; } } Ok(()) @@ -653,7 +788,9 @@ fn export_adjustments_as_lut( context: &Arc, state: &tauri::State, app_handle: &tauri::AppHandle, + cancellation_token: &AtomicBool, ) -> Result, String> { + ensure_export_not_cancelled(cancellation_token)?; let lut_size = 33; let identity_image = generate_identity_lut_image(lut_size); @@ -693,25 +830,11 @@ fn export_adjustments_as_lut( }, "export_lut", )?; + ensure_export_not_cancelled(cancellation_token)?; - convert_image_to_cube_lut(&processed_lut, lut_size) -} - -struct ExportHandleGuard { - app_handle: tauri::AppHandle, -} - -impl Drop for ExportHandleGuard { - fn drop(&mut self) { - if let Ok(mut handle_lock) = self - .app_handle - .state::() - .export_task_handle - .lock() - { - *handle_lock = None; - } - } + let cube_lut = convert_image_to_cube_lut(&processed_lut, lut_size)?; + ensure_export_not_cancelled(cancellation_token)?; + Ok(cube_lut) } #[allow(clippy::too_many_arguments)] @@ -728,13 +851,28 @@ pub async fn export_images( state: tauri::State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result<(), String> { + let cancellation_token = register_export_task(&state.export_task_token)?; + let task_guard = ExportTaskGuard::with_app_handle( + Arc::clone(&state.export_task_token), + Arc::clone(&cancellation_token), + app_handle.clone(), + ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; - if state.export_task_handle.lock().unwrap().is_some() { - return Err("An export is already in progress.".to_string()); + if cancellation_token.load(Ordering::SeqCst) { + return Ok(()); + } + + let context = match get_or_init_gpu_context(&state, &app_handle) { + Ok(context) => context, + Err(_) if cancellation_token.load(Ordering::SeqCst) => return Ok(()), + Err(error) => return Err(error), + }; + + if cancellation_token.load(Ordering::SeqCst) { + return Ok(()); } - let context = get_or_init_gpu_context(&state, &app_handle)?; let context = Arc::new(context); let progress_counter = Arc::new(AtomicUsize::new(0)); @@ -762,10 +900,8 @@ pub async fn export_images( num_threads ); - let task = tokio::spawn(async move { - let _export_guard = ExportHandleGuard { - app_handle: app_handle.clone(), - }; + let _export_task = tokio::spawn(async move { + let _task_guard = task_guard; let output_folder_path = std::path::Path::new(&output_folder_or_file); let total_paths = paths.len(); let settings = load_settings(app_handle.clone()).unwrap_or_default(); @@ -805,7 +941,14 @@ pub async fn export_images( let mut join_handles = Vec::new(); for (global_index, image_path_str, appearance_count, explicit_vc) in export_items { + if cancellation_token.load(Ordering::SeqCst) { + break; + } let permit = semaphore.clone().acquire_owned().await.unwrap(); + if cancellation_token.load(Ordering::SeqCst) { + drop(permit); + break; + } let app_handle_clone = app_handle.clone(); let context_clone = Arc::clone(&context); @@ -817,17 +960,10 @@ pub async fn export_images( let current_edit_path = current_edit_path.clone(); let current_edit_adjustments = current_edit_adjustments.clone(); let settings = settings.clone(); + let cancellation_token_clone = Arc::clone(&cancellation_token); let handle = tokio::task::spawn_blocking(move || { - if app_handle_clone - .state::() - .export_task_handle - .lock() - .unwrap() - .is_none() - { - return Err("Export cancelled".to_string()); - } + ensure_export_not_cancelled(&cancellation_token_clone)?; let state = app_handle_clone.state::(); let (source_path, sidecar_path) = parse_virtual_path(&image_path_str); @@ -896,7 +1032,9 @@ pub async fn export_images( &context_clone, &state, &app_handle_clone, + &cancellation_token_clone, )?; + ensure_export_not_cancelled(&cancellation_token_clone)?; #[cfg(target_os = "android")] { let file_name = output_path @@ -911,6 +1049,7 @@ pub async fn export_images( } #[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(()); } @@ -960,6 +1099,7 @@ pub async fn export_images( } } }; + ensure_export_not_cancelled(&cancellation_token_clone)?; let mut main_export_adjustments = js_adjustments.clone(); if export_settings.export_masks @@ -978,16 +1118,19 @@ pub async fn export_images( is_raw, &app_handle_clone, )?; + ensure_export_not_cancelled(&cancellation_token_clone)?; save_image_with_metadata( &final_image, &output_path, &source_path_str, &export_settings, )?; + ensure_export_not_cancelled(&cancellation_token_clone)?; if export_settings.preserve_timestamps { set_timestamps_from_exif(Path::new(&source_path_str), &output_path); } + ensure_export_not_cancelled(&cancellation_token_clone)?; if export_settings.export_masks { export_masks_for_image( @@ -1000,24 +1143,32 @@ pub async fn export_images( &state, is_raw, &app_handle_clone, + &cancellation_token_clone, )?; } Ok(()) })(); - let current_progress = progress_counter_clone.fetch_add(1, Ordering::SeqCst) + 1; - let _ = app_handle_clone.emit( - "batch-export-progress", - serde_json::json!({ - "current": current_progress, - "total": total_paths, - "path": &image_path_str - }), - ); + if !cancellation_token_clone.load(Ordering::SeqCst) { + let current_progress = + progress_counter_clone.fetch_add(1, Ordering::SeqCst) + 1; + let _ = app_handle_clone.emit( + "batch-export-progress", + serde_json::json!({ + "current": current_progress, + "total": total_paths, + "path": &image_path_str + }), + ); + } drop(permit); - result + if cancellation_token_clone.load(Ordering::SeqCst) { + Err("Export cancelled".to_string()) + } else { + result + } }); join_handles.push(handle); @@ -1033,43 +1184,64 @@ pub async fn export_images( tokio::time::sleep(std::time::Duration::from_millis(150)).await; - let mut error_count = 0; - for result in results { - if let Err(e) = result { - error_count += 1; - log::error!("Export error: {}", e); - if total_paths == 1 { - let _ = app_handle.emit("export-error", e); + let errors: Vec = results.into_iter().filter_map(Result::err).collect(); + let error_count = errors.len(); + let export_state = app_handle.state::(); + let finalized = finish_export_task( + &export_state.export_task_token, + &cancellation_token, + |cancelled| { + if cancelled { + log::info!("Batch export cancelled and worker cleanup completed"); + let _ = app_handle.emit("export-cancelled", ()); + return; } - } - } - if error_count > 0 && total_paths > 1 { - let _ = app_handle.emit( - "export-complete-with-errors", - serde_json::json!({ "errors": error_count, "total": total_paths }), - ); - } else if error_count == 0 { - let _ = app_handle.emit( - "batch-export-progress", - serde_json::json!({ "current": total_paths, "total": total_paths, "path": "" }), - ); - let _ = app_handle.emit("export-complete", ()); + for error in &errors { + log::error!("Export error: {}", error); + if total_paths == 1 { + let _ = app_handle.emit("export-error", error.clone()); + } + } + + if error_count > 0 && total_paths > 1 { + let _ = app_handle.emit( + "export-error", + format!("{error_count} of {total_paths} exports failed"), + ); + } else if error_count == 0 { + let _ = app_handle.emit( + "batch-export-progress", + serde_json::json!({ "current": total_paths, "total": total_paths, "path": "" }), + ); + let _ = app_handle.emit("export-complete", ()); + } + }, + ); + + if !finalized { + log::warn!("Ignoring terminal events from a stale export task"); } }); - *state.export_task_handle.lock().unwrap() = Some(task); Ok(()) } #[tauri::command] -pub fn cancel_export(state: tauri::State) -> Result<(), String> { - match state.export_task_handle.lock().unwrap().take() { - Some(handle) => { - handle.abort(); - println!("Export task cancellation requested."); +pub fn cancel_export( + state: tauri::State, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + match request_export_cancellation(&state.export_task_token, || { + let _ = app_handle.emit("export-cancelling", ()); + }) { + ExportCancellationRequest::Requested => { + log::info!("Export cancellation requested; workers will stop at the next checkpoint"); } - _ => { + ExportCancellationRequest::AlreadyRequested => { + log::info!("Export cancellation was already requested"); + } + ExportCancellationRequest::NoActiveTask => { return Err("No export task is currently running.".to_string()); } } @@ -1361,3 +1533,116 @@ pub async fn estimate_export_sizes( Ok(single_image_extrapolated_size * paths.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_cancellation_check_is_idempotent() { + let cancellation_token = AtomicBool::new(false); + ensure_export_not_cancelled(&cancellation_token).unwrap(); + + cancellation_token.store(true, Ordering::SeqCst); + assert_eq!( + ensure_export_not_cancelled(&cancellation_token).unwrap_err(), + "Export cancelled" + ); + assert_eq!( + ensure_export_not_cancelled(&cancellation_token).unwrap_err(), + "Export cancelled" + ); + } + + #[test] + fn cancellation_requested_during_startup_is_preserved_and_emitted_once() { + let task_token = Mutex::new(None); + let cancellation_token = register_export_task(&task_token).unwrap(); + let emitted_count = AtomicUsize::new(0); + + assert_eq!( + request_export_cancellation(&task_token, || { + emitted_count.fetch_add(1, Ordering::SeqCst); + }), + ExportCancellationRequest::Requested + ); + assert!(cancellation_token.load(Ordering::SeqCst)); + + assert_eq!( + request_export_cancellation(&task_token, || { + emitted_count.fetch_add(1, Ordering::SeqCst); + }), + ExportCancellationRequest::AlreadyRequested + ); + assert_eq!(emitted_count.load(Ordering::SeqCst), 1); + + let mut finalized_as_cancelled = false; + assert!(finish_export_task( + &task_token, + &cancellation_token, + |cancelled| finalized_as_cancelled = cancelled, + )); + assert!(finalized_as_cancelled); + assert!(task_token.lock().unwrap().is_none()); + } + + #[test] + fn completion_prevents_a_late_cancellation_event() { + let task_token = Mutex::new(None); + let cancellation_token = register_export_task(&task_token).unwrap(); + let completed_count = AtomicUsize::new(0); + let cancelled_count = AtomicUsize::new(0); + let callback_invoked = AtomicBool::new(false); + + assert!(finish_export_task( + &task_token, + &cancellation_token, + |cancelled| { + assert!(!cancelled); + callback_invoked.store(true, Ordering::SeqCst); + completed_count.fetch_add(1, Ordering::SeqCst); + }, + )); + assert!(callback_invoked.load(Ordering::SeqCst)); + assert!(task_token.lock().unwrap().is_none()); + assert_eq!( + request_export_cancellation(&task_token, || { + cancelled_count.fetch_add(1, Ordering::SeqCst); + }), + ExportCancellationRequest::NoActiveTask + ); + assert_eq!(completed_count.load(Ordering::SeqCst), 1); + assert_eq!(cancelled_count.load(Ordering::SeqCst), 0); + } + + #[test] + fn overlapping_export_registration_is_rejected() { + let task_token = Mutex::new(None); + let cancellation_token = register_export_task(&task_token).unwrap(); + + assert_eq!( + register_export_task(&task_token).unwrap_err(), + "An export is already in progress." + ); + assert!(finish_export_task(&task_token, &cancellation_token, |_| {},)); + assert!(register_export_task(&task_token).is_ok()); + } + + #[test] + fn stale_export_cannot_clear_or_complete_a_newer_task() { + let task_token = Mutex::new(None); + let stale_token = register_export_task(&task_token).unwrap(); + let current_token = Arc::new(AtomicBool::new(false)); + *task_token.lock().unwrap() = Some(Arc::clone(¤t_token)); + let stale_terminal_count = AtomicUsize::new(0); + + assert!(!finish_export_task(&task_token, &stale_token, |_| { + stale_terminal_count.fetch_add(1, Ordering::SeqCst); + },)); + assert_eq!(stale_terminal_count.load(Ordering::SeqCst), 0); + assert!(Arc::ptr_eq( + task_token.lock().unwrap().as_ref().unwrap(), + ¤t_token + )); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a28f3517..5cfe24a43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,7 +105,7 @@ extern "C" fn force_exit(_signal: libc::c_int) { #[cfg(target_os = "macos")] pub fn register_exit_handler() { unsafe { - libc::signal(libc::SIGABRT, force_exit as libc::sighandler_t); + libc::signal(libc::SIGABRT, force_exit as *const () as libc::sighandler_t); } } @@ -1873,9 +1873,14 @@ fn frontend_ready( #[cfg(not(target_os = "android"))] { + #[cfg(any(windows, target_os = "linux"))] let mut should_maximize = false; + #[cfg(any(windows, target_os = "linux"))] let mut should_fullscreen = false; + #[cfg(not(any(windows, target_os = "linux")))] + let _ = (&app_handle, is_first_run); + #[cfg(any(windows, target_os = "linux"))] if is_first_run && let Ok(config_dir) = app_handle.path().app_config_dir() { let path = config_dir.join("window_state.json"); @@ -1924,6 +1929,7 @@ fn frontend_ready( if let Err(e) = window.set_focus() { log::error!("Failed to focus window: {}", e); } + #[cfg(any(windows, target_os = "linux"))] if is_first_run { if should_maximize { let _ = window.maximize(); @@ -2271,7 +2277,7 @@ pub fn run() { gpu_processor: Mutex::new(None), ai_state: Mutex::new(None), ai_init_lock: TokioMutex::new(()), - export_task_handle: Mutex::new(None), + export_task_token: Arc::new(Mutex::new(None)), hdr_result: Arc::new(Mutex::new(None)), panorama_result: Arc::new(Mutex::new(None)), denoise_result: Arc::new(Mutex::new(None)), @@ -2405,14 +2411,13 @@ pub fn run() { match event { #[cfg(target_os = "macos")] tauri::RunEvent::Opened { urls } => { - if let Some(url) = urls.first() { - if let Ok(path) = url.to_file_path() { - if let Some(path_str) = path.to_str() { - let state = app_handle.state::(); - *state.initial_file_path.lock().unwrap() = Some(path_str.to_string()); - log::info!("macOS initial open: Stored path {} for later.", path_str); - } - } + if let Some(url) = urls.first() + && let Ok(path) = url.to_file_path() + && let Some(path_str) = path.to_str() + { + let state = app_handle.state::(); + *state.initial_file_path.lock().unwrap() = Some(path_str.to_string()); + log::info!("macOS initial open: Stored path {} for later.", path_str); } } tauri::RunEvent::ExitRequested { api, .. } => { diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 583a788a3..b120e000a 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -282,7 +282,8 @@ export default function ExportPanel({ const isAndroid = osPlatform === 'android'; const { status, progress, errorMessage } = exportState; - const isExporting = status === Status.Exporting; + const isExporting = [Status.Exporting, Status.Cancelling].includes(status); + const isCancelling = status === Status.Cancelling; const isLibraryContext = !!onClose; const pathsToExport = isLibraryContext @@ -537,10 +538,16 @@ export default function ExportPanel({ }; const handleCancel = async () => { + setExportState((current: ExportState) => + current.status === Status.Exporting ? { status: Status.Cancelling } : {}, + ); try { await invoke(Invokes.CancelExport); } catch (error) { console.error('Failed to cancel:', error); + setExportState((current: ExportState) => + current.status === Status.Cancelling ? { status: Status.Exporting } : {}, + ); } }; @@ -565,12 +572,14 @@ export default function ExportPanel({ {canExport ? ( <> - + + + @@ -706,12 +715,14 @@ export default function ExportPanel({ /> {enableWatermark && ( - setWatermarkPath(null)} - /> + + setWatermarkPath(null)} + /> + {watermarkPath && ( <> setIsAdvancedExpanded(!isAdvancedExpanded)} + disabled={isExporting} className="w-full flex items-center justify-between p-3.5 hover:bg-card-active transition-colors" > @@ -890,6 +904,10 @@ export default function ExportPanel({ {t('export.status.cancelExport')} > + ) : status === Status.Cancelling ? ( + <> + {t('export.status.cancelling')} + > ) : status === Status.Success ? ( <> {t('export.status.success')} diff --git a/src/components/ui/ExportImportProperties.tsx b/src/components/ui/ExportImportProperties.tsx index a51dff33a..ec647ba0b 100644 --- a/src/components/ui/ExportImportProperties.tsx +++ b/src/components/ui/ExportImportProperties.tsx @@ -90,6 +90,7 @@ export interface ImportState { export enum Status { Cancelled = 'cancelled', + Cancelling = 'cancelling', Exporting = 'exporting', Error = 'error', Idle = 'idle', diff --git a/src/hooks/useTauriListeners.ts b/src/hooks/useTauriListeners.ts index 004bd45a1..6b0ff0a68 100644 --- a/src/hooks/useTauriListeners.ts +++ b/src/hooks/useTauriListeners.ts @@ -166,6 +166,9 @@ export function useTauriListeners({ 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 }); }), diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index a9961e991..774dc3f68 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -780,6 +780,7 @@ "status": { "cancelExport": "Export abbrechen", "cancelled": "Export abgebrochen", + "cancelling": "Export wird abgebrochen…", "estimatedAverageSize": " ({{size}} Ø)", "estimatedSize": "Geschätzte Dateigröße: ~{{size}}", "estimatedTotalSize": "Geschätzte Gesamtgröße: ~{{size}}", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4c8818a6e..296626cfd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -780,6 +780,7 @@ "status": { "cancelExport": "Cancel Export", "cancelled": "Export cancelled", + "cancelling": "Cancelling export…", "estimatedAverageSize": " ({{size}} avg)", "estimatedSize": "Estimated file size: ~{{size}}", "estimatedTotalSize": "Estimated total size: ~{{size}}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 68db75e4e..6e0241f94 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -802,6 +802,7 @@ "status": { "cancelExport": "Cancelar exportación", "cancelled": "Exportación cancelada", + "cancelling": "Cancelando exportación…", "estimatedAverageSize": " ({{size}} prom)", "estimatedSize": "Tamaño de archivo estimado: ~{{size}}", "estimatedTotalSize": "Tamaño total estimado: ~{{size}}", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 33673c381..fa68dbecd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -802,6 +802,7 @@ "status": { "cancelExport": "Annuler l'exportation", "cancelled": "Exportation annulée", + "cancelling": "Annulation de l'exportation…", "estimatedAverageSize": " ({{size}} en moy.)", "estimatedSize": "Taille estimée du fichier : ~{{size}}", "estimatedTotalSize": "Taille totale estimée : ~{{size}}", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index eb65c62d6..659c87ca1 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -802,6 +802,7 @@ "status": { "cancelExport": "Annulla Esportazione", "cancelled": "Esportazione annullata", + "cancelling": "Annullamento esportazione…", "estimatedAverageSize": " ({{size}} media)", "estimatedSize": "Dimensione file stimata: ~{{size}}", "estimatedTotalSize": "Dimensione totale stimata: ~{{size}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ecd9c6e46..5c1483e2f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -778,6 +778,7 @@ "status": { "cancelExport": "書き出しをキャンセル", "cancelled": "書き出しがキャンセルされました", + "cancelling": "書き出しをキャンセル中…", "estimatedAverageSize": " (平均 {{size}})", "estimatedSize": "推定ファイルサイズ: ~{{size}}", "estimatedTotalSize": "推定合計サイズ: ~{{size}}", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 8db0ec1a5..0f07db0cb 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -778,6 +778,7 @@ "status": { "cancelExport": "내보내기 취소", "cancelled": "내보내기가 취소되었습니다", + "cancelling": "내보내기 취소 중…", "estimatedAverageSize": " ({{size}} 평균)", "estimatedSize": "예상 파일 크기: ~{{size}}", "estimatedTotalSize": "예상 총 크기: ~{{size}}", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index c7733c2e4..de41445f1 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -824,6 +824,7 @@ "status": { "cancelExport": "Anuluj eksport", "cancelled": "Eksport anulowany", + "cancelling": "Anulowanie eksportu…", "estimatedAverageSize": " (śr. {{size}})", "estimatedSize": "Szacowany rozmiar pliku: ~{{size}}", "estimatedTotalSize": "Szacowany rozmiar całkowity: ~{{size}}", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 8617dc493..4eb00185f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -802,6 +802,7 @@ "status": { "cancelExport": "Cancelar Exportação", "cancelled": "Exportação cancelada", + "cancelling": "Cancelando exportação…", "estimatedAverageSize": " ({{size}} em média)", "estimatedSize": "Tamanho estimado do arquivo: ~{{size}}", "estimatedTotalSize": "Tamanho total estimado: ~{{size}}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c925c305a..2fb4ecf27 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -824,6 +824,7 @@ "status": { "cancelExport": "Отменить экспорт", "cancelled": "Экспорт отменен", + "cancelling": "Отмена экспорта…", "estimatedAverageSize": " (~{{size}} в среднем)", "estimatedSize": "Оценочный размер файла: ~{{size}}", "estimatedTotalSize": "Оценочный общий размер: ~{{size}}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 31d2b1aea..b8de8315a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -778,6 +778,7 @@ "status": { "cancelExport": "取消导出", "cancelled": "导出已取消", + "cancelling": "正在取消导出…", "estimatedAverageSize": "(平均 {{size}})", "estimatedSize": "预计文件大小:约 {{size}}", "estimatedTotalSize": "预计总大小:约 {{size}}", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a2a09ca1c..a3bc10ed0 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -778,6 +778,7 @@ "status": { "cancelExport": "取消匯出", "cancelled": "匯出已取消", + "cancelling": "正在取消匯出…", "estimatedAverageSize": "(平均 {{size}})", "estimatedSize": "預計檔案大小:約 {{size}}", "estimatedTotalSize": "預計總大小:約 {{size}}",