diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cd239ad..06f5031 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1153,9 +1153,9 @@ pub async fn invalidate_duplicate_scan_cache( pub async fn delete_images_from_disk( db: State<'_, DbState>, params: DeleteImagesFromDiskParams, -) -> Result { +) -> Result, String> { if params.image_ids.is_empty() { - return Ok(0); + return Ok(Vec::new()); } let conn = db.get().map_err(|e| e.to_string())?; let records = db::get_all_image_paths(&conn, None).map_err(|e| e.to_string())?; @@ -1170,11 +1170,11 @@ pub async fn delete_images_from_disk( succeeded_ids.push(r.id); } } - let deleted = succeeded_ids.len(); if !succeeded_ids.is_empty() { db::delete_images_by_ids(&conn, &succeeded_ids).map_err(|e| e.to_string())?; } - Ok(deleted) + // Return the IDs that were actually removed so the caller can update state precisely. + Ok(succeeded_ids) } #[tauri::command] diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 0ee42f5..054370a 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -175,9 +175,13 @@ export function Lightbox() { useEffect(() => { if (!selectedImage) return; + // Capture the ID so a stale response for image A cannot overwrite B's tags + // when the user navigates before the request resolves. + let cancelled = false; void getImageTags(selectedImage.id) - .then(setImageTags) - .catch(() => setImageTags([])); + .then((tags) => { if (!cancelled) setImageTags(tags); }) + .catch(() => { if (!cancelled) setImageTags([]); }); + return () => { cancelled = true; }; }, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]); // Reset the queued state once the worker finishes so the button is usable again diff --git a/src/store.ts b/src/store.ts index 3d0bd28..82be8d4 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1513,20 +1513,33 @@ export const useGalleryStore = create((set, get) => ({ clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }), deleteSelectedDuplicates: async () => { - const { duplicateSelectedIds, duplicateScanFolderId } = get(); + const { duplicateSelectedIds, duplicateGroups } = get(); const ids = Array.from(duplicateSelectedIds); if (ids.length === 0) return 0; - const deleted = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); - // Remove deleted images from groups and drop now-trivial groups + // Backend returns only the IDs that were actually removed from disk. + const succeededIds = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); + const succeededSet = new Set(succeededIds); + // Only remove images confirmed deleted — failed files remain visible so the user can retry. set((state) => ({ duplicateSelectedIds: new Set(), duplicateGroups: state.duplicateGroups - .map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) })) + .map((g) => ({ ...g, images: g.images.filter((img) => !succeededSet.has(img.id)) })) .filter((g) => g.images.length > 1), })); - // Invalidate the persisted cache so a restart doesn't reload stale entries - await invoke("invalidate_duplicate_scan_cache", { folderId: duplicateScanFolderId ?? null }); - return deleted; + // Invalidate the persisted cache for every affected scope: + // - global "all" cache (always, since a folder-scoped deletion still makes the global result stale) + // - each folder that contained a deleted image (so a folder-scoped scan is also evicted) + const affectedFolderIds = new Set( + duplicateGroups + .flatMap((g) => g.images) + .filter((img) => succeededSet.has(img.id)) + .map((img) => img.folder_id), + ); + await invoke("invalidate_duplicate_scan_cache", { folderId: null }); // global + for (const folderId of affectedFolderIds) { + await invoke("invalidate_duplicate_scan_cache", { folderId }); + } + return succeededIds.length; }, retryFailedEmbeddings: async (folderId) => {