fix(duplicates): close three more P2 correctness issues from review

- commands: delete_images_from_disk now returns Vec<i64> of IDs that
  were actually removed from disk rather than a bare count, so callers
  can act precisely on what succeeded vs. what failed

- store: deleteSelectedDuplicates uses the returned ID set to filter
  duplicate groups (files that failed to delete stay visible for retry)
  and invalidates both the global "all" cache and each affected folder
  cache, not just the currently-viewed scope

- Lightbox: guard the getImageTags effect with a cancellation flag so
  a response that arrives after the user has navigated to another image
  cannot overwrite the new image's tag list
This commit is contained in:
2026-06-07 23:02:51 +01:00
parent ae3d6e1034
commit 1f6650021c
3 changed files with 30 additions and 13 deletions
+4 -4
View File
@@ -1153,9 +1153,9 @@ pub async fn invalidate_duplicate_scan_cache(
pub async fn delete_images_from_disk( pub async fn delete_images_from_disk(
db: State<'_, DbState>, db: State<'_, DbState>,
params: DeleteImagesFromDiskParams, params: DeleteImagesFromDiskParams,
) -> Result<usize, String> { ) -> Result<Vec<i64>, String> {
if params.image_ids.is_empty() { if params.image_ids.is_empty() {
return Ok(0); return Ok(Vec::new());
} }
let conn = db.get().map_err(|e| e.to_string())?; 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())?; 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); succeeded_ids.push(r.id);
} }
} }
let deleted = succeeded_ids.len();
if !succeeded_ids.is_empty() { if !succeeded_ids.is_empty() {
db::delete_images_by_ids(&conn, &succeeded_ids).map_err(|e| e.to_string())?; 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] #[tauri::command]
+6 -2
View File
@@ -175,9 +175,13 @@ export function Lightbox() {
useEffect(() => { useEffect(() => {
if (!selectedImage) return; 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) void getImageTags(selectedImage.id)
.then(setImageTags) .then((tags) => { if (!cancelled) setImageTags(tags); })
.catch(() => setImageTags([])); .catch(() => { if (!cancelled) setImageTags([]); });
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]); }, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// Reset the queued state once the worker finishes so the button is usable again // Reset the queued state once the worker finishes so the button is usable again
+20 -7
View File
@@ -1513,20 +1513,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }), clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
deleteSelectedDuplicates: async () => { deleteSelectedDuplicates: async () => {
const { duplicateSelectedIds, duplicateScanFolderId } = get(); const { duplicateSelectedIds, duplicateGroups } = get();
const ids = Array.from(duplicateSelectedIds); const ids = Array.from(duplicateSelectedIds);
if (ids.length === 0) return 0; if (ids.length === 0) return 0;
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } }); // Backend returns only the IDs that were actually removed from disk.
// Remove deleted images from groups and drop now-trivial groups const succeededIds = await invoke<number[]>("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) => ({ set((state) => ({
duplicateSelectedIds: new Set(), duplicateSelectedIds: new Set(),
duplicateGroups: state.duplicateGroups 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), .filter((g) => g.images.length > 1),
})); }));
// Invalidate the persisted cache so a restart doesn't reload stale entries // Invalidate the persisted cache for every affected scope:
await invoke("invalidate_duplicate_scan_cache", { folderId: duplicateScanFolderId ?? null }); // - global "all" cache (always, since a folder-scoped deletion still makes the global result stale)
return deleted; // - each folder that contained a deleted image (so a folder-scoped scan is also evicted)
const affectedFolderIds = new Set<number>(
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) => { retryFailedEmbeddings: async (folderId) => {