Files
phokus/src/store/librarySlice.ts
T
LyAhn 52ac2543ec refactor(store): split store.ts into feature slices under src/store/
Break the 3,244-line monolithic store into a Zustand slice-per-feature
layout (types, helpers, librarySlice, gallerySlice, searchSlice,
exploreSlice, albumSlice, duplicateSlice, taggerSlice, captionSlice,
settingsSlice, appSlice, events) combined in index.ts. Components keep
calling useGalleryStore(s => s.field) against the same flat state
object — no component changes required. Completes the de-godify effort
started with the menu/Dropdown/Sidebar extractions and icons.tsx.
2026-07-04 15:53:52 +01:00

174 lines
6.7 KiB
TypeScript

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<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
addFolders: (paths: string[]) => Promise<FolderAddResult[]>;
listDirectories: (path: string | null) => Promise<import("./types").DirListing>;
removeFolder: (folderId: number) => Promise<void>;
reindexFolder: (folderId: number) => Promise<void>;
renameFolder: (folderId: number, newName: string) => Promise<void>;
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
reorderFolders: (folderIds: number[]) => Promise<void>;
selectFolder: (folderId: number | null) => void;
setViewFolderScope: (folderId: number | null) => void;
retryFailedEmbeddings: (folderId: number) => Promise<void>;
}
export const createLibrarySlice: StateCreator<GalleryStore, [], [], LibrarySlice> = (set, get) => ({
folders: [],
selectedFolderId: null,
indexingProgress: {},
mediaJobProgress: {},
loadFolders: async () => {
const folders = await invoke<Folder[]>("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<FolderJobProgress[]>("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<FolderAddResult[]>("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();
},
});