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:
@@ -1153,9 +1153,9 @@ pub async fn invalidate_duplicate_scan_cache(
|
||||
pub async fn delete_images_from_disk(
|
||||
db: State<'_, DbState>,
|
||||
params: DeleteImagesFromDiskParams,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<Vec<i64>, 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-7
@@ -1513,20 +1513,33 @@ export const useGalleryStore = create<GalleryState>((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<number>("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<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) => ({
|
||||
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<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) => {
|
||||
|
||||
Reference in New Issue
Block a user