diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7be9948..db7639e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -264,10 +264,15 @@ pub async fn remove_folder( .into_iter() .find(|f| f.id == folder_id) .map(|f| PathBuf::from(f.path)); + // Collect thumbnail paths before the cascade delete removes the rows. + let thumb_paths = db::get_thumbnail_paths_for_folder(&conn, folder_id).unwrap_or_default(); db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())?; if let Some(path) = folder_path { watcher.remove_folder(&path); } + for thumb in &thumb_paths { + let _ = std::fs::remove_file(thumb); + } Ok(()) } @@ -1192,6 +1197,9 @@ pub async fn delete_images_from_disk( for r in records.into_iter().filter(|r| id_set.contains(&r.id)) { if std::fs::remove_file(&r.path).is_ok() { succeeded_ids.push(r.id); + if let Some(thumb) = &r.thumbnail_path { + let _ = std::fs::remove_file(thumb); + } } } if !succeeded_ids.is_empty() { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b2c02ba..2123d05 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1676,6 +1676,7 @@ pub fn search_tags_autocomplete( pub struct ImagePathRecord { pub id: i64, pub path: String, + pub thumbnail_path: Option, } pub fn get_all_image_paths( @@ -1683,19 +1684,69 @@ pub fn get_all_image_paths( folder_id: Option, ) -> Result> { let mut stmt = conn.prepare( - "SELECT id, path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id", + "SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id", )?; let rows = stmt .query_map(params![folder_id], |row| { Ok(ImagePathRecord { id: row.get(0)?, path: row.get(1)?, + thumbnail_path: row.get(2)?, }) })? .collect::>>()?; Ok(rows) } +/// Returns (image_id, thumbnail_path) for the given path. Used by the watcher +/// delete branch so it can clean up the thumbnail file in the same step. +pub fn get_image_id_and_thumbnail_by_path( + conn: &Connection, + path: &str, +) -> Result)>> { + let result = conn.query_row( + "SELECT id, thumbnail_path FROM images WHERE path = ?1", + params![path], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)), + ); + match result { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Returns all non-null thumbnail_path values for images in a folder. +/// Called before folder deletion so callers can remove the files from disk. +pub fn get_thumbnail_paths_for_folder( + conn: &Connection, + folder_id: i64, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT thumbnail_path FROM images WHERE folder_id = ?1 AND thumbnail_path IS NOT NULL", + )?; + let rows = stmt + .query_map([folder_id], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows) +} + +/// Updates a moved/renamed image's path and thumbnail_path in-place. +/// Pass `new_thumbnail_path = None` to clear it (triggers regeneration). +pub fn update_image_path( + conn: &Connection, + image_id: i64, + new_path: &str, + new_filename: &str, + new_thumbnail_path: Option<&str>, +) -> Result<()> { + conn.execute( + "UPDATE images SET path = ?2, filename = ?3, thumbnail_path = ?4 WHERE id = ?1", + params![image_id, new_path, new_filename, new_thumbnail_path], + )?; + Ok(()) +} + pub fn get_explore_tags( conn: &Connection, folder_id: Option, diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 704c466..95bd5ae 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -1347,7 +1347,7 @@ impl WatcherHandle { /// - `recv()` when no events are pending — zero CPU /// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly /// when the soonest debounce window expires, no busy-polling -pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle { +pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> WatcherHandle { let (tx, rx) = std::sync::mpsc::channel::>(); let raw_watcher = notify::recommended_watcher(move |result| { @@ -1392,17 +1392,19 @@ pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle { std::thread::spawn(move || { // path → deadline: the earliest instant at which this path should be processed. let mut pending: HashMap = HashMap::new(); + // old_path → new_path for rename events that carry both sides atomically. + let mut pending_renames: Vec<(PathBuf, PathBuf)> = Vec::new(); loop { // Adaptive blocking: block forever when idle, wake at the earliest // deadline when events are queued. - let received = if pending.is_empty() { + let received = if pending.is_empty() && pending_renames.is_empty() { match rx.recv() { Ok(e) => Some(e), Err(_) => break, // sender dropped — app is shutting down } } else { - let earliest = pending.values().copied().min().unwrap(); // safe: non-empty + let earliest = pending.values().copied().min().unwrap_or(Instant::now()); let timeout = earliest.saturating_duration_since(Instant::now()); match rx.recv_timeout(timeout) { Ok(e) => Some(e), @@ -1415,17 +1417,40 @@ pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle { // Absorb incoming event — coalesces rapid writes into one deadline. if let Some(Ok(event)) = received { - use notify::EventKind; - // Skip pure access events (reads); they never change file content. + use notify::{EventKind, event::{ModifyKind, RenameMode}}; if !matches!(event.kind, EventKind::Access(_)) { - for path in event.paths { - if is_supported_media(&path) { - pending.insert(path, now + WATCHER_DEBOUNCE); + // Intercept atomic rename events (old + new path in one event). + // Handle these separately to preserve embeddings and thumbnails. + if matches!( + event.kind, + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) + ) && event.paths.len() == 2 + { + let old = event.paths[0].clone(); + let new = event.paths[1].clone(); + if is_supported_media(&old) || is_supported_media(&new) { + // Remove either side from regular pending so it isn't + // processed as an independent delete/create. + pending.remove(&old); + pending.remove(&new); + pending_renames.push((old, new)); + } + } else { + for path in event.paths { + if is_supported_media(&path) { + pending.insert(path, now + WATCHER_DEBOUNCE); + } } } } } + // Process renames immediately — they are atomic filesystem operations + // and do not benefit from debouncing. + for (old_path, new_path) in pending_renames.drain(..) { + process_watcher_rename(&app, &pool, &folder_map_thread, &thumb_dir, &old_path, &new_path); + } + // Process all paths whose debounce window has expired. let ready: Vec = pending .iter() @@ -1496,11 +1521,14 @@ fn process_watcher_path( Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e), } } else { - // File removed from disk — clean up DB row. + // File removed from disk — clean up DB row and thumbnail. let path_str = path.to_string_lossy(); - match db::get_image_id_by_path(&conn, &path_str) { - Ok(Some(image_id)) => { + match db::get_image_id_and_thumbnail_by_path(&conn, &path_str) { + Ok(Some((image_id, thumb_path))) => { if db::delete_images_by_ids(&conn, &[image_id]).is_ok() { + if let Some(thumb) = thumb_path { + let _ = std::fs::remove_file(thumb); + } db::update_folder_count(&conn, folder_id).ok(); let _ = app.emit("watcher-deleted", vec![image_id]); let _ = app.emit("folder-counts-changed", ()); @@ -1511,3 +1539,76 @@ fn process_watcher_path( } } } + +/// Handles a filesystem rename/move event where both the old and new paths are +/// known. Updates the DB row in-place (preserving the embedding) and renames +/// the thumbnail file to match the new path hash. +fn process_watcher_rename( + app: &AppHandle, + pool: &DbPool, + folder_map: &Arc>>, + thumb_dir: &Path, + old_path: &Path, + new_path: &Path, +) { + // Resolve folder_id from the old path first, fall back to new path. + let folder_id = { + let map = folder_map.lock().unwrap(); + map.iter() + .find(|(fp, _)| old_path.starts_with(fp.as_path()) || new_path.starts_with(fp.as_path())) + .map(|(_, &id)| id) + }; + let Some(folder_id) = folder_id else { return }; + + let conn = match pool.get() { + Ok(c) => c, + Err(e) => { + eprintln!("Watcher rename: DB pool error: {}", e); + return; + } + }; + + let old_path_str = old_path.to_string_lossy(); + let new_path_str = new_path.to_string_lossy(); + + let (image_id, old_thumb) = match db::get_image_id_and_thumbnail_by_path(&conn, &old_path_str) { + Ok(Some(record)) => record, + Ok(None) => { + // Not yet indexed under the old path — treat as a brand-new file. + drop(conn); + process_watcher_path(app, pool, folder_map, new_path); + return; + } + Err(e) => { + eprintln!("Watcher rename: DB lookup error: {}", e); + return; + } + }; + + // Compute new thumbnail path and attempt to rename the file on disk. + let new_thumb_path = crate::thumbnail::thumb_path(thumb_dir, &new_path_str); + let new_thumb_str = new_thumb_path.to_string_lossy().into_owned(); + let thumb_ok = old_thumb + .as_deref() + .map(|old| std::fs::rename(old, &new_thumb_path).is_ok()) + .unwrap_or(false); + // If rename failed (e.g. old thumb never existed), clear thumbnail_path so + // the thumbnail worker regenerates it. + let effective_thumb: Option<&str> = if thumb_ok { Some(&new_thumb_str) } else { None }; + + let new_filename = new_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(""); + + if let Err(e) = db::update_image_path(&conn, image_id, &new_path_str, new_filename, effective_thumb) { + eprintln!("Watcher rename: DB update error: {}", e); + return; + } + + // Emit the updated record so the frontend refreshes without a full reload. + match db::get_image_by_id(&conn, image_id) { + Ok(record) => emit_images(app, &IndexedImagesBatch { folder_id, images: vec![record] }), + Err(e) => eprintln!("Watcher rename: post-update fetch error: {}", e), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00ba11a..02346f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,7 +74,7 @@ pub fn run() { // indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone()); indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone()); - let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone()); + let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone()); app.manage(pool); app.manage(media_tools);