fix: five PR review bugs and folder-recovery UX for missing/renamed folders

BackgroundTasks: dismiss no longer calls clearTaggingJobs; dismissal only
updates local dismissed state

DuplicateFinder: entering the view clears stale groups when the stored scan
scope differs from selectedFolderId; duplicateScanFolderId is recorded on
each scan and cache load so scope is always tracked

Tagger threshold: set_tagger_threshold now sets TAGGER_SESSION_DIRTY so the
worker rebuilds its WdTagger instance and applies the new threshold on the
next batch

Region search global scope: fetch offset+limit+2 candidates before removing
the source image so has_more is accurate across pages

Region search pagination: loadMoreImages now sets loadingImages and checks
galleryRequestToken before appending results, preventing duplicate pages and
stale responses corrupting a new collection

Folder recovery: when index_folder detects a missing path it records the
error in a new folders.scan_error column (ensure_column migration) and emits
done without deleting images, preserving them for recovery. A new
update_folder_path command rewrites image paths in the DB before reindexing
so thumbnails and embeddings are not needlessly regenerated for unchanged
files. The sidebar shows an amber warning icon on affected folders and a
recovery banner with Locate Folder and Remove actions
This commit is contained in:
2026-06-07 09:00:32 +01:00
parent 9e08d9cce3
commit 7871d52d39
8 changed files with 203 additions and 35 deletions
-2
View File
@@ -57,7 +57,6 @@ export function BackgroundTasks() {
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false);
@@ -130,7 +129,6 @@ export function BackgroundTasks() {
const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
void clearTaggingJobs(id);
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false);
};
+47 -5
View File
@@ -11,9 +11,18 @@ function FolderItem({
selected: boolean;
progress: IndexProgress | undefined;
}) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
const { selectFolder, removeFolder, reindexFolder, updateFolderPath } = useGalleryStore();
const isIndexing = progress && !progress.done;
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const isMissing = !!folder.scan_error && !isIndexing;
const handleLocateFolder = async (e: React.MouseEvent) => {
e.stopPropagation();
const selected = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
if (selected && typeof selected === "string") {
await updateFolderPath(folder.id, selected);
}
};
useEffect(() => {
if (!confirmingRemoval) return;
@@ -31,6 +40,7 @@ function FolderItem({
};
return (
<>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
@@ -39,10 +49,19 @@ function FolderItem({
}`}
onClick={() => selectFolder(folder.id)}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
{folder.scan_error ? (
<span title={folder.scan_error} className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
) : (
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
)}
<div className="flex-1 min-w-0">
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
@@ -112,6 +131,29 @@ function FolderItem({
</div>
)}
</div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
onClick={handleLocateFolder}
>
Locate Folder
</button>
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
onClick={(e) => { e.stopPropagation(); void removeFolder(folder.id); }}
>
Remove
</button>
</div>
</div>
)}
</>
);
}
+51 -21
View File
@@ -10,6 +10,7 @@ export interface Folder {
name: string;
image_count: number;
indexed_at: string | null;
scan_error: string | null;
}
export type MediaKind = "image" | "video";
@@ -279,12 +280,14 @@ interface GalleryState {
duplicateScanProgress: { scanned: number; total: number } | null;
duplicateSelectedIds: Set<number>;
duplicateLastScanned: number | null; // Unix timestamp (seconds)
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
reindexFolder: (folderId: number) => Promise<void>;
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
selectFolder: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>;
@@ -611,6 +614,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
duplicateScanProgress: null,
duplicateSelectedIds: new Set(),
duplicateLastScanned: null,
duplicateScanFolderId: undefined,
setCacheDir: (cacheDir) => set({ cacheDir }),
@@ -667,6 +671,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await loadBackgroundJobProgress();
},
updateFolderPath: async (folderId, newPath) => {
const { loadFolders, loadBackgroundJobProgress } = get();
await invoke("update_folder_path", { folderId, newPath });
await loadFolders();
await loadBackgroundJobProgress();
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
void get().loadImages(true);
@@ -785,24 +796,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
if (!similarHasMore) return;
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
params: {
image_id: similarSourceImageId,
crop_x: similarCrop.x,
crop_y: similarCrop.y,
crop_w: similarCrop.w,
crop_h: similarCrop.h,
folder_id: similarFolderId,
offset: loadedCount,
limit: PAGE_SIZE,
},
});
set((state) => ({
images: [...state.images, ...result.images],
loadedCount: state.loadedCount + result.images.length,
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
similarHasMore: result.has_more,
}));
const requestToken = ++galleryRequestToken;
set({ loadingImages: true });
try {
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
params: {
image_id: similarSourceImageId,
crop_x: similarCrop.x,
crop_y: similarCrop.y,
crop_w: similarCrop.w,
crop_h: similarCrop.h,
folder_id: similarFolderId,
offset: loadedCount,
limit: PAGE_SIZE,
},
});
if (requestToken !== galleryRequestToken) return;
set((state) => ({
images: [...state.images, ...result.images],
loadedCount: state.loadedCount + result.images.length,
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
similarHasMore: result.has_more,
loadingImages: false,
}));
} catch {
if (requestToken !== galleryRequestToken) return;
set({ loadingImages: false });
}
return;
}
await get().loadImages(false);
@@ -858,7 +878,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
openImage: (image) => set({ selectedImage: image }),
closeImage: () => set({ selectedImage: null }),
setView: (activeView) => set({ activeView }),
setView: (activeView) => {
if (activeView === "duplicates") {
const { selectedFolderId, duplicateScanFolderId } = get();
if (duplicateScanFolderId !== selectedFolderId) {
set({ activeView, duplicateGroups: [], duplicateLastScanned: null, duplicateScanFolderId: undefined });
void get().loadDuplicateScanCache(selectedFolderId);
return;
}
}
set({ activeView });
},
setExploreMode: (exploreMode) => set({ exploreMode }),
@@ -1409,7 +1439,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
if (cached) {
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at });
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at, duplicateScanFolderId: folderId });
}
},
@@ -1422,7 +1452,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000), duplicateScanFolderId: folderId });
void notifyTaskComplete(
"Duplicate scan complete",
groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`,