diff --git a/CHANGELOG.md b/CHANGELOG.md index 83828d9..2c7e5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) so the info panel shows more at a glance with less scrolling. - **Faster Explore revisits** — returning to a folder's visual clusters should feel much faster now, even in big libraries. +- **Calmer Tag Cloud during AI tagging** — Explore no longer keeps hammering the + tag list while a folder is actively being tagged, so tagging stays smoother and + the cloud catches up once the work settles. - **Faster first-time clustering** — large libraries build their first visual clusters much more quickly, while still keeping the groups nicely balanced. - **Better tag browsing** — the Tag manager now has live search, sorting diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index d486fa3..c93f798 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -303,6 +303,7 @@ pub fn migrate(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id); CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source); CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); + CREATE INDEX IF NOT EXISTS idx_image_tags_tag_image_id ON image_tags(tag, image_id); CREATE TABLE IF NOT EXISTS albums ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -2446,32 +2447,42 @@ pub fn get_explore_tags( limit: usize, ) -> Result> { let mut stmt = conn.prepare( - "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, - MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, - MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source - FROM image_tags t - JOIN images i ON i.id = t.image_id - WHERE (?1 IS NULL OR i.folder_id = ?1) - GROUP BY t.tag - HAVING COUNT(DISTINCT t.image_id) >= 1 - ORDER BY tag_count DESC, t.tag ASC - LIMIT ?2", + "WITH tag_counts AS ( + SELECT t.tag, + COUNT(DISTINCT t.image_id) AS tag_count, + MIN(t.image_id) AS representative_image_id, + MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, + MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source + FROM image_tags t + JOIN images i ON i.id = t.image_id + WHERE (?1 IS NULL OR i.folder_id = ?1) + GROUP BY t.tag + HAVING COUNT(DISTINCT t.image_id) >= 1 + ORDER BY tag_count DESC, t.tag ASC + LIMIT ?2 + ) + SELECT c.tag, + c.tag_count, + c.representative_image_id, + i.thumbnail_path, + c.has_ai_source, + c.has_user_source + FROM tag_counts c + JOIN images i ON i.id = c.representative_image_id + ORDER BY c.tag_count DESC, c.tag ASC", )?; let rows = stmt .query_map(params![folder_id, limit as i64], |row| { let representative_image_id = row.get::<_, i64>(2)?; - let thumbnail_path = get_image_by_id(conn, representative_image_id) - .ok() - .and_then(|image| image.thumbnail_path); Ok(ExploreTagEntry { tag: row.get(0)?, count: row.get(1)?, representative_image_id, - thumbnail_path, - has_ai_source: row.get::<_, i64>(3)? != 0, - has_user_source: row.get::<_, i64>(4)? != 0, + thumbnail_path: row.get(3)?, + has_ai_source: row.get::<_, i64>(4)? != 0, + has_user_source: row.get::<_, i64>(5)? != 0, }) })? .collect::>>()?; diff --git a/src/store.ts b/src/store.ts index 1cd1d50..5b7fed1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -651,6 +651,34 @@ let galleryRequestToken = 0; let visualClusterRequestToken = 0; let exploreTagRequestToken = 0; let exploreTagRefreshTimer: ReturnType | null = null; +const EXPLORE_TAG_REFRESH_IDLE_MS = 900; +const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500; + +function scopeHasTaggingPending( + progressByFolder: Record, + folderId: number | null, +): boolean { + if (folderId === null) { + return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0); + } + return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0; +} + +function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean { + return scopeFolderId === null || scopeFolderId === progressFolderId; +} + +function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean { + return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId); +} + +function scheduleExploreTagRefresh(load: () => void, delayMs: number) { + if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer); + exploreTagRefreshTimer = setTimeout(() => { + exploreTagRefreshTimer = null; + load(); + }, delayMs); +} function initialAiCaptionsEnabled(): boolean { if (typeof window === "undefined") return false; @@ -1498,6 +1526,9 @@ export const useGalleryStore = create((set, get) => ({ loadExploreTags: async (options) => { const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); const force = options?.force ?? false; + if (!force && exploreTagLoading) { + return; + } if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { return; } @@ -2984,9 +3015,18 @@ export const useGalleryStore = create((set, get) => ({ notificationTimers.set(taggingKey, setTimeout(() => { notificationTimers.delete(taggingKey); void notifyTaskComplete("AI tagging complete", body); - // New tags landed — invalidate Explore tag cache. - set({ exploreTagsFolderId: undefined }); }, NOTIFICATION_DEBOUNCE_MS)); + const state = get(); + if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) { + // New tags landed — refresh the Explore tag cloud after the worker + // settles instead of waiting for the user to revisit Explore. + set({ exploreTagsFolderId: undefined }); + if (state.activeView === "explore" && state.exploreMode === "tags") { + scheduleExploreTagRefresh(() => { + void get().loadExploreTags({ force: true }); + }, EXPLORE_TAG_REFRESH_IDLE_MS); + } + } } else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) { clearTimeout(notificationTimers.get(taggingKey)); notificationTimers.delete(taggingKey); @@ -3067,15 +3107,16 @@ export const useGalleryStore = create((set, get) => ({ const unlistenThumbnails = await listen("media-updated", (event) => { const batch = event.payload; const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); - if (taggingUpdated) { + const state = get(); + if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) { set({ exploreTagsFolderId: undefined }); - if (get().activeView === "explore") { - if (!exploreTagRefreshTimer) { - exploreTagRefreshTimer = setTimeout(() => { - exploreTagRefreshTimer = null; - void get().loadExploreTags({ force: true }); - }, 700); - } + if (state.activeView === "explore" && state.exploreMode === "tags") { + const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId) + ? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS + : EXPLORE_TAG_REFRESH_IDLE_MS; + scheduleExploreTagRefresh(() => { + void get().loadExploreTags({ force: true }); + }, delay); } } @@ -3179,6 +3220,10 @@ export const useGalleryStore = create((set, get) => ({ ); return () => { + if (exploreTagRefreshTimer) { + clearTimeout(exploreTagRefreshTimer); + exploreTagRefreshTimer = null; + } unlistenProgress(); unlistenMediaJobs(); unlistenCaptionModelProgress();