feat(sidebar): pause/resume all background work from folder context menu

The onboarding tour tells users they can right-click a folder to pause its
background work, but only the per-worker buttons in the BackgroundTasks bar
existed. Add a 'Pause/Resume background work' context-menu item that toggles
all four workers (thumbnail/metadata/embedding/tagging) for the folder.

To keep the bar and the menu in sync, worker-pause state moves from
BackgroundTasks' local state into the store (the canonical owner of app
state), so pausing from either surface reflects in both. Reuses the existing
set_worker_paused / get_worker_states commands; no backend change.
This commit is contained in:
2026-06-13 09:42:51 +01:00
parent 2f66b0bdb8
commit 075c7e4cfb
3 changed files with 96 additions and 52 deletions
+9 -52
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store"; import { useGalleryStore, WorkerKey } from "../store";
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = { const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail", Thumbnails: "thumbnail",
@@ -38,21 +36,6 @@ interface FailedEmbeddingItem {
error: string | null; error: string | null;
} }
interface FolderWorkerStates {
folder_id: number;
thumbnail_paused: boolean;
metadata_paused: boolean;
embedding_paused: boolean;
tagging_paused: boolean;
}
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
thumbnail: false,
metadata: false,
embedding: false,
tagging: false,
};
export function BackgroundTasks() { export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress);
@@ -63,32 +46,15 @@ export function BackgroundTasks() {
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({}); const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
useEffect(() => { const workerPaused = useGalleryStore((state) => state.workerPaused);
const folderIds = folders.map((folder) => folder.id); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
if (folderIds.length === 0) { const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused);
setPaused({});
return;
}
invoke<FolderWorkerStates[]>("get_worker_states", { folderIds }).then((states) => { useEffect(() => {
setPaused( void loadWorkerStates();
Object.fromEntries( }, [folders, loadWorkerStates]);
states.map((state) => [
state.folder_id,
{
thumbnail: state.thumbnail_paused,
metadata: state.metadata_paused,
embedding: state.embedding_paused,
tagging: state.tagging_paused,
},
]),
),
);
});
}, [folders]);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change. // Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo( const failedCounts = useMemo(
@@ -113,20 +79,11 @@ export function BackgroundTasks() {
}, [expanded, failedCounts]); }, [expanded, failedCounts]);
const isWorkerPaused = (folderId: number, worker: WorkerKey) => { const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return paused[folderId]?.[worker] ?? DEFAULT_PAUSED_STATE[worker]; return workerPaused[folderId]?.[worker] ?? false;
}; };
const toggleWorker = (folderId: number, worker: WorkerKey) => { const toggleWorker = (folderId: number, worker: WorkerKey) => {
const next = !isWorkerPaused(folderId, worker); setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker));
setPaused((prev) => ({
...prev,
[folderId]: {
...DEFAULT_PAUSED_STATE,
...prev[folderId],
[worker]: next,
},
}));
void invoke("set_worker_paused", { worker, folderId, paused: next });
}; };
const dismissTask = (id: number, snapshot: string) => { const dismissTask = (id: number, snapshot: string) => {
+12
View File
@@ -12,21 +12,25 @@ function FolderContextMenu({
menu, menu,
folder, folder,
isMuted, isMuted,
isPausedAll,
onClose, onClose,
onRename, onRename,
onReindex, onReindex,
onLocate, onLocate,
onToggleMute, onToggleMute,
onTogglePauseAll,
onRemove, onRemove,
}: { }: {
menu: ContextMenuState; menu: ContextMenuState;
folder: Folder; folder: Folder;
isMuted: boolean; isMuted: boolean;
isPausedAll: boolean;
onClose: () => void; onClose: () => void;
onRename: () => void; onRename: () => void;
onReindex: () => void; onReindex: () => void;
onLocate: () => void; onLocate: () => void;
onToggleMute: () => void; onToggleMute: () => void;
onTogglePauseAll: () => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -65,6 +69,7 @@ function FolderContextMenu({
> >
{item("Reindex", onReindex)} {item("Reindex", onReindex)}
{item("Rename", onRename)} {item("Rename", onRename)}
{item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)}
{item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)} {item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)}
{folder.scan_error && item("Locate Folder", onLocate)} {folder.scan_error && item("Locate Folder", onLocate)}
<div className="my-1 border-t border-white/[0.06]" /> <div className="my-1 border-t border-white/[0.06]" />
@@ -84,7 +89,12 @@ function FolderItem({
}) { }) {
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]);
const isMuted = mutedFolderIds.includes(folder.id); const isMuted = mutedFolderIds.includes(folder.id);
// "Fully paused" only when every worker for this folder is paused.
const isPausedAll = !!folderWorkers &&
folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging;
const isIndexing = progress && !progress.done; const isIndexing = progress && !progress.done;
const isMissing = !!folder.scan_error && !isIndexing; const isMissing = !!folder.scan_error && !isIndexing;
@@ -258,11 +268,13 @@ function FolderItem({
menu={contextMenu} menu={contextMenu}
folder={folder} folder={folder}
isMuted={isMuted} isMuted={isMuted}
isPausedAll={isPausedAll}
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
onRename={() => setRenaming(true)} onRename={() => setRenaming(true)}
onReindex={() => void reindexFolder(folder.id)} onReindex={() => void reindexFolder(folder.id)}
onLocate={() => void handleLocateFolder()} onLocate={() => void handleLocateFolder()}
onToggleMute={() => toggleMutedFolder(folder.id)} onToggleMute={() => toggleMutedFolder(folder.id)}
onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)}
onRemove={() => setConfirmingRemoval(true)} onRemove={() => setConfirmingRemoval(true)}
/> />
)} )}
+75
View File
@@ -266,6 +266,18 @@ export type SortOrder =
export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error"; export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error";
export type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
const WORKER_KEYS: WorkerKey[] = ["thumbnail", "metadata", "embedding", "tagging"];
interface FolderWorkerStates {
folder_id: number;
thumbnail_paused: boolean;
metadata_paused: boolean;
embedding_paused: boolean;
tagging_paused: boolean;
}
export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error"; export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error";
interface FfmpegProgressEvent { interface FfmpegProgressEvent {
@@ -329,6 +341,9 @@ interface GalleryState {
taggingQueueFolderIds: number[]; taggingQueueFolderIds: number[];
mutedFolderIds: number[]; mutedFolderIds: number[];
notificationsPaused: boolean; notificationsPaused: boolean;
// Per-folder background-worker pause flags, shared by the BackgroundTasks
// bar and the sidebar folder context menu.
workerPaused: Record<number, Record<WorkerKey, boolean>>;
appVersion: string | null; appVersion: string | null;
updateStatus: UpdateStatus; updateStatus: UpdateStatus;
@@ -421,6 +436,9 @@ interface GalleryState {
toggleMutedFolder: (folderId: number) => void; toggleMutedFolder: (folderId: number) => void;
loadNotificationsPaused: () => Promise<void>; loadNotificationsPaused: () => Promise<void>;
setNotificationsPaused: (paused: boolean) => void; setNotificationsPaused: (paused: boolean) => void;
loadWorkerStates: () => Promise<void>;
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
loadAppVersion: () => Promise<void>; loadAppVersion: () => Promise<void>;
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>; checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
installUpdate: () => Promise<void>; installUpdate: () => Promise<void>;
@@ -708,6 +726,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggingQueueFolderIds: [], taggingQueueFolderIds: [],
mutedFolderIds: [], mutedFolderIds: [],
notificationsPaused: false, notificationsPaused: false,
workerPaused: {},
appVersion: null, appVersion: null,
updateStatus: "idle", updateStatus: "idle",
@@ -1526,6 +1545,62 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void invoke("set_notifications_paused", { paused }).catch(() => {}); void invoke("set_notifications_paused", { paused }).catch(() => {});
}, },
loadWorkerStates: async () => {
const folderIds = get().folders.map((folder) => folder.id);
if (folderIds.length === 0) {
set({ workerPaused: {} });
return;
}
try {
const states = await invoke<FolderWorkerStates[]>("get_worker_states", { folderIds });
set({
workerPaused: Object.fromEntries(
states.map((state) => [
state.folder_id,
{
thumbnail: state.thumbnail_paused,
metadata: state.metadata_paused,
embedding: state.embedding_paused,
tagging: state.tagging_paused,
},
]),
),
});
} catch {
// leave the existing snapshot in place
}
},
setWorkerPaused: (folderId, worker, paused) => {
set((state) => {
const current = state.workerPaused[folderId] ?? {
thumbnail: false,
metadata: false,
embedding: false,
tagging: false,
};
return {
workerPaused: {
...state.workerPaused,
[folderId]: { ...current, [worker]: paused },
},
};
});
void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {});
},
setAllWorkersPaused: (folderId, paused) => {
set((state) => ({
workerPaused: {
...state.workerPaused,
[folderId]: { thumbnail: paused, metadata: paused, embedding: paused, tagging: paused },
},
}));
for (const worker of WORKER_KEYS) {
void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {});
}
},
loadAppVersion: async () => { loadAppVersion: async () => {
try { try {
set({ appVersion: await getVersion() }); set({ appVersion: await getVersion() });