diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4936847..434cfd4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -325,6 +325,37 @@ pub async fn reindex_folder( Ok(()) } +#[tauri::command] +pub async fn update_folder_path( + app: AppHandle, + db: State<'_, DbState>, + folder_id: i64, + new_path: String, +) -> Result<(), String> { + let new_path_buf = PathBuf::from(&new_path); + if !new_path_buf.is_dir() { + return Err(format!("Path is not a valid directory: {}", new_path)); + } + let new_name = new_path_buf + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| new_path.clone()); + { + let conn = db.get().map_err(|e| e.to_string())?; + // Fetch the old path before updating so image paths can be rewritten. + let old_path = db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .find(|f| f.id == folder_id) + .map(|f| f.path) + .ok_or("Folder not found")?; + db::update_folder_path(&conn, folder_id, &old_path, &new_path, &new_name) + .map_err(|e| e.to_string())?; + } + indexer::index_folder(app, db.inner().clone(), folder_id, new_path_buf); + Ok(()) +} + #[tauri::command] pub async fn find_similar_images( db: State<'_, DbState>, @@ -403,9 +434,10 @@ pub async fn find_similar_by_region( ) .map_err(|e| e.to_string())?, None => { - let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 1) + // Fetch one extra candidate to compensate for the source image that + // will be removed, so has_more is accurate and results span multiple pages. + let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) .map_err(|e| e.to_string())?; - // Exclude the source image from global results ids.retain(|&id| id != params.image_id); ids } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 15134a2..549e59d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -30,6 +30,7 @@ pub struct Folder { pub name: String, pub image_count: i64, pub indexed_at: Option, + pub scan_error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,7 +173,8 @@ pub fn migrate(conn: &Connection) -> Result<()> { path TEXT NOT NULL UNIQUE, name TEXT NOT NULL, image_count INTEGER NOT NULL DEFAULT 0, - indexed_at TEXT + indexed_at TEXT, + scan_error TEXT ); CREATE TABLE IF NOT EXISTS images ( @@ -304,6 +306,7 @@ pub fn migrate(conn: &Connection) -> Result<()> { ensure_column(conn, "images", "ai_tagger_model", "TEXT")?; ensure_column(conn, "images", "ai_tagged_at", "TEXT")?; ensure_column(conn, "images", "ai_tagger_error", "TEXT")?; + ensure_column(conn, "folders", "scan_error", "TEXT")?; vector::migrate(conn)?; Ok(()) @@ -1339,7 +1342,7 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result Result> { let mut stmt = - conn.prepare("SELECT id, path, name, image_count, indexed_at FROM folders ORDER BY name")?; + conn.prepare("SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name")?; let rows = stmt.query_map([], |row| { Ok(Folder { id: row.get(0)?, @@ -1347,11 +1350,43 @@ pub fn get_folders(conn: &Connection) -> Result> { name: row.get(2)?, image_count: row.get(3)?, indexed_at: row.get(4)?, + scan_error: row.get(5)?, }) })?; Ok(rows.collect::>>()?) } +pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> { + conn.execute( + "UPDATE folders SET path = ?2, name = ?3, scan_error = NULL WHERE id = ?1", + params![folder_id, new_path, new_name], + )?; + // Rewrite image paths so the indexer can match them by path and skip + // re-generating thumbnails and embeddings for unchanged files. + // SQLite's replace() does a literal prefix substitution on each path. + conn.execute( + "UPDATE images SET path = replace(path, ?1, ?2) WHERE folder_id = ?3", + params![old_path, new_path, folder_id], + )?; + Ok(()) +} + +pub fn set_folder_scan_error(conn: &Connection, folder_id: i64, error: &str) -> Result<()> { + conn.execute( + "UPDATE folders SET scan_error = ?2 WHERE id = ?1", + params![folder_id, error], + )?; + Ok(()) +} + +pub fn clear_folder_scan_error(conn: &Connection, folder_id: i64) -> Result<()> { + conn.execute( + "UPDATE folders SET scan_error = NULL WHERE id = ?1", + [folder_id], + )?; + Ok(()) +} + pub fn get_images( conn: &Connection, folder_id: Option, diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index f619dcf..a5d062d 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -147,11 +147,39 @@ pub struct MediaJobProgressEvent { pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) { std::thread::spawn(move || { + set_folder_indexing_state(folder_id, true); + + // If the folder path no longer exists on disk, record the error and + // emit done. Images are intentionally kept in the DB so the user can + // choose to relocate the folder or remove it explicitly — they should + // not be silently destroyed. + if !folder_path.is_dir() { + let error_msg = format!("Folder not found: {}", folder_path.display()); + eprintln!("Indexing error for folder {}: {}", folder_id, error_msg); + if let Ok(conn) = pool.get() { + let _ = db::set_folder_scan_error(&conn, folder_id, &error_msg); + } + emit_progress( + &app, + &IndexProgress { + folder_id, + total: 0, + indexed: 0, + current_file: String::new(), + done: true, + }, + ); + set_folder_indexing_state(folder_id, false); + return; + } + let storage_profile = detect_storage_profile(&folder_path); set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile)); - set_folder_indexing_state(folder_id, true); - if let Err(error) = do_index(app.clone(), pool, folder_id, folder_path) { + if let Err(error) = do_index(app.clone(), &pool, folder_id, folder_path) { eprintln!("Indexing error for folder {}: {}", folder_id, error); + if let Ok(conn) = pool.get() { + let _ = db::set_folder_scan_error(&conn, folder_id, &error.to_string()); + } // Always emit done so the frontend reloads and recovers from partial state. emit_progress( &app, @@ -246,7 +274,7 @@ pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) }); } -fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> { +fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> { let existing_entries = { let conn = pool.get()?; db::get_folder_media_index(&conn, folder_id)? @@ -344,6 +372,7 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) } let _ = db::backfill_embedding_jobs(&conn)?; db::update_folder_count(&conn, folder_id)?; + let _ = db::clear_folder_scan_error(&conn, folder_id); } emit_progress( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c820db..26522b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -133,6 +133,7 @@ pub fn run() { commands::find_duplicates, commands::load_duplicate_scan_cache, commands::delete_images_from_disk, + commands::update_folder_path, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/tagger.rs b/src-tauri/src/tagger.rs index 48522bd..94a284e 100644 --- a/src-tauri/src/tagger.rs +++ b/src-tauri/src/tagger.rs @@ -191,6 +191,7 @@ pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result std::fs::create_dir_all(parent)?; } std::fs::write(path, clamped.to_string())?; + TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed); Ok(clamped) } diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index 4515215..62ead7b 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -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); }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1aad9d5..84c039f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 ( + <>
selectFolder(folder.id)} > - - - + {folder.scan_error ? ( + + + + + + ) : ( + + + + )}
@@ -112,6 +131,29 @@ function FolderItem({
)}
+ {isMissing && ( +
+

Folder not found

+

+ This folder may have been moved or renamed. Locate it to resume, or remove it from the app. +

+
+ + +
+
+ )} + ); } diff --git a/src/store.ts b/src/store.ts index 9730662..1ebb358 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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; duplicateLastScanned: number | null; // Unix timestamp (seconds) + duplicateScanFolderId: number | null | undefined; // undefined = never scanned loadFolders: () => Promise; loadBackgroundJobProgress: () => Promise; addFolder: (path: string) => Promise; removeFolder: (folderId: number) => Promise; reindexFolder: (folderId: number) => Promise; + updateFolderPath: (folderId: number, newPath: string) => Promise; selectFolder: (folderId: number | null) => void; loadImages: (reset?: boolean) => Promise; loadMoreImages: () => Promise; @@ -611,6 +614,7 @@ export const useGalleryStore = create((set, get) => ({ duplicateScanProgress: null, duplicateSelectedIds: new Set(), duplicateLastScanned: null, + duplicateScanFolderId: undefined, setCacheDir: (cacheDir) => set({ cacheDir }), @@ -667,6 +671,13 @@ export const useGalleryStore = create((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((set, get) => ({ } if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) { if (!similarHasMore) return; - const result = await invoke("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("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((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((set, get) => ({ interface CacheResult { groups: DuplicateGroup[]; scanned_at: number } const cached = await invoke("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((set, get) => ({ }); try { const groups = await invoke("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.`,