import { invoke } from "@tauri-apps/api/core"; import type { StateCreator } from "zustand"; import type { GalleryStore } from "./index"; import type { Folder, FolderAddResult, FolderJobProgress, IndexProgress } from "./types"; export interface LibrarySlice { folders: Folder[]; selectedFolderId: number | null; indexingProgress: Record; mediaJobProgress: Record; loadFolders: () => Promise; loadBackgroundJobProgress: () => Promise; addFolder: (path: string) => Promise; addFolders: (paths: string[]) => Promise; listDirectories: (path: string | null) => Promise; removeFolder: (folderId: number) => Promise; reindexFolder: (folderId: number) => Promise; renameFolder: (folderId: number, newName: string) => Promise; updateFolderPath: (folderId: number, newPath: string) => Promise; reorderFolders: (folderIds: number[]) => Promise; selectFolder: (folderId: number | null) => void; setViewFolderScope: (folderId: number | null) => void; retryFailedEmbeddings: (folderId: number) => Promise; } export const createLibrarySlice: StateCreator = (set, get) => ({ folders: [], selectedFolderId: null, indexingProgress: {}, mediaJobProgress: {}, loadFolders: async () => { const folders = await invoke("get_folders"); set((state) => { const folderIds = new Set(folders.map((folder) => folder.id)); const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId)); return { folders, taggingQueueFolderIds: nextSelected, }; }); }, loadBackgroundJobProgress: async () => { const progress = await invoke("get_background_job_progress"); set(() => ({ mediaJobProgress: Object.fromEntries(progress.map((entry) => [entry.folder_id, entry])), })); }, addFolder: async (path) => { const { loadFolders, loadBackgroundJobProgress } = get(); await invoke("add_folder", { path }); await loadFolders(); await loadBackgroundJobProgress(); }, addFolders: async (paths) => { const { loadFolders, loadBackgroundJobProgress } = get(); const results = await invoke("add_folders", { paths }); await loadFolders(); await loadBackgroundJobProgress(); return results; }, listDirectories: (path) => invoke("list_directories", { path }), removeFolder: async (folderId) => { const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get(); // Optimistically drop it from the sidebar for instant feedback (the backend // delete of its images/thumbnails can take a moment), clearing the active // selection if it was this folder. set((state) => { const folders = state.folders.filter((folder) => folder.id !== folderId); return selectedFolderId === folderId ? { folders, selectedFolderId: null } : { folders }; }); try { await invoke("remove_folder", { folderId }); } catch (error) { // Removal failed — resync the authoritative list and surface the error. await loadFolders(); throw error; } await loadFolders(); await loadBackgroundJobProgress(); // Invalidate tag cloud and explore-tags cache since library content changed. set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined }); // Always refresh the gallery: the removed folder's images may be on screen // (e.g. in All Media), not only when that folder was the active selection. await loadImages(true); }, reindexFolder: async (folderId) => { const { loadFolders, loadBackgroundJobProgress } = get(); await invoke("reindex_folder", { folderId }); await loadFolders(); // Invalidate tag cloud cache since embeddings will be regenerated set({ visualClusterFolderId: undefined, visualClusterEntries: [] }); await loadBackgroundJobProgress(); }, renameFolder: async (folderId, newName) => { await invoke("rename_folder", { folderId, newName }); await get().loadFolders(); }, updateFolderPath: async (folderId, newPath) => { const { loadFolders, loadBackgroundJobProgress } = get(); await invoke("update_folder_path", { folderId, newPath }); await loadFolders(); await loadBackgroundJobProgress(); }, reorderFolders: async (folderIds) => { const previous = get().folders; const byId = new Map(previous.map((folder) => [folder.id, folder])); const folders = folderIds .map((id, index) => { const folder = byId.get(id); return folder ? { ...folder, sort_order: index + 1 } : null; }) .filter((folder): folder is Folder => folder !== null); set({ folders }); try { await invoke("reorder_folders", { params: { folder_ids: folderIds } }); } catch (error) { set({ folders: previous }); throw error; } }, selectFolder: (folderId) => { // Leaving any album: drop the album-origin scope so the Folder/All pills // highlight correctly again. const similarScope = get().similarScope === "current_album" ? "all_media" : get().similarScope; set({ selectedFolderId: folderId, selectedAlbumId: null, similarSourceAlbumId: null, similarScope, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null }); void get().loadImages(true); }, // Change folder scope from inside a feature view (Timeline/Explore/Duplicates) // without leaving it — unlike selectFolder, activeView is preserved. setViewFolderScope: (folderId) => { const { activeView, selectedFolderId } = get(); if (folderId === selectedFolderId) return; set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); if (activeView === "duplicates") { const { duplicateScanFolderId } = get(); if (duplicateScanFolderId !== folderId) { set({ duplicateGroups: [], duplicateLastScanned: null, duplicateScanFolderId: undefined, duplicateScanWarning: null, }); void get().loadDuplicateScanCache(folderId); } return; } // Explore reloads itself via ExploreView's useEffect on selectedFolderId. if (activeView === "explore") return; void get().loadImages(true); }, retryFailedEmbeddings: async (folderId) => { await invoke("retry_failed_embeddings", { params: { folder_id: folderId } }); await get().loadBackgroundJobProgress(); }, });