import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useGalleryStore } from "../store"; type WorkerKey = "thumbnail" | "metadata" | "embedding"; const WORKER_FOR_STAGE: Record = { Thumbnails: "thumbnail", Metadata: "metadata", Embeddings: "embedding", }; interface TaskStage { label: string; detail: string; progress: number | null; // 0–100, or null for indeterminate failed: boolean; } interface Task { id: number; name: string; stages: TaskStage[]; hasFailedEmbeddings: boolean; pendingMediaWork: number; embeddingProcessed: number; embeddingTotal: number; currentFile: string | null; snapshot: string; } export function BackgroundTasks() { const folders = useGalleryStore((state) => state.folders); const indexingProgress = useGalleryStore((state) => state.indexingProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); const [paused, setPaused] = useState>({ thumbnail: false, metadata: false, embedding: false, }); useEffect(() => { invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>( "get_worker_states", ).then((states) => { setPaused({ thumbnail: states.thumbnail_paused, metadata: states.metadata_paused, embedding: states.embedding_paused, }); }); }, []); const toggleWorker = (worker: WorkerKey) => { const next = !paused[worker]; setPaused((prev) => ({ ...prev, [worker]: next })); void invoke("set_worker_paused", { worker, paused: next }); }; const dismissTask = (id: number, snapshot: string) => { setDismissed((prev) => ({ ...prev, [id]: snapshot })); setExpanded(false); }; const tasks = useMemo(() => { return folders .map((folder): Task | null => { const index = indexingProgress[folder.id]; const jobs = mediaJobProgress[folder.id]; const thumbnailPending = jobs?.thumbnail_pending ?? 0; const metadataPending = jobs?.metadata_pending ?? 0; const embeddingPending = jobs?.embedding_pending ?? 0; const embeddingReady = jobs?.embedding_ready ?? 0; const embeddingFailed = jobs?.embedding_failed ?? 0; const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending; const embeddingProcessed = embeddingReady + embeddingFailed; const embeddingTotal = embeddingProcessed + embeddingPending; const hasFailedEmbeddings = embeddingFailed > 0; if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null; const stages: TaskStage[] = []; if (index && !index.done) { const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0; stages.push({ label: "Scanning", detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, progress: pct, failed: false, }); } if (thumbnailPending > 0) { stages.push({ label: "Thumbnails", detail: thumbnailPending.toLocaleString(), progress: null, failed: false, }); } if (metadataPending > 0) { stages.push({ label: "Metadata", detail: metadataPending.toLocaleString(), progress: null, failed: false, }); } if (embeddingPending > 0) { const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0; stages.push({ label: "Embeddings", detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, progress: pct, failed: false, }); } if (hasFailedEmbeddings && pendingMediaWork === 0) { stages.push({ label: "Failed", detail: `${embeddingFailed.toLocaleString()} embeddings`, progress: null, failed: true, }); } const snapshot = `${pendingMediaWork}:${embeddingFailed}`; return { id: folder.id, name: folder.name, stages, hasFailedEmbeddings, pendingMediaWork, embeddingProcessed, embeddingTotal, currentFile: index && !index.done ? (index.current_file || null) : null, snapshot, }; }) .filter((t): t is Task => t !== null) .filter((t) => dismissed[t.id] !== t.snapshot); }, [folders, indexingProgress, mediaJobProgress, dismissed]); if (tasks.length === 0) return null; const primary = tasks[0]; const extraCount = tasks.length - 1; const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0); // Best progress bar value: use embedding progress if available (most informative), // otherwise fall back to scanning progress, otherwise indeterminate. const embeddingStage = primary.stages.find((s) => s.label === "Embeddings"); const scanningStage = primary.stages.find((s) => s.label === "Scanning"); const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null; return (
{/* Slim bar */}
setExpanded((v) => !v)} > {/* Pulse dot */}
{/* Folder name */} {primary.name} {/* Stage tags — all active stages visible simultaneously */}
{primary.stages.map((stage) => { const workerKey = WORKER_FOR_STAGE[stage.label]; const isPaused = workerKey ? paused[workerKey] : false; return ( {stage.label} {stage.detail} {workerKey && ( )} ); })}
{/* Progress bar — embedding or scanning progress, pulsing if indeterminate */}
{/* Extra folders badge */} {extraCount > 0 && ( +{extraCount} )} {/* Retry (failed embeddings only) */} {primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && ( )} {/* Expand chevron (only when multiple folders) */} {tasks.length > 1 && ( )} {/* Dismiss */}
{/* Expanded panel — one row per folder */} {expanded && (
{tasks.map((task) => { const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null; const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0; return (
{task.name}
{task.stages.map((stage) => { const workerKey = WORKER_FOR_STAGE[stage.label]; const isPaused = workerKey ? paused[workerKey] : false; return ( {isPaused && ( )} {stage.label} {stage.detail} {workerKey && ( )} ); })}
{taskHasFailed && ( )}
{task.currentFile && (

{task.currentFile}

)}
); })}
)}
); }