From a34d38d9d3cc087c4cd2c0027660a600f4ead934 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Thu, 11 Jun 2026 06:28:16 +0100 Subject: [PATCH] feat: notification batching, per-folder mute, and global pause Debounce embedding/tagging completion notifications per folder (6s window) so rapid file additions from downloaders fire one notification instead of one per file. Add per-folder mute via the sidebar context menu and a global pause toggle in Settings > General, both persisted across restarts. --- src-tauri/src/commands.rs | 65 +++++++++++++++++ src-tauri/src/lib.rs | 4 + src/App.tsx | 4 + src/components/SettingsModal.tsx | 22 ++++++ src/components/Sidebar.tsx | 11 ++- src/store.ts | 121 ++++++++++++++++++++++++------- 6 files changed, 200 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index db7639e..14f8465 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1852,3 +1852,68 @@ pub async fn cleanup_orphaned_thumbnails( freed_mb: freed_bytes as f64 / 1_048_576.0, }) } + +const MUTED_FOLDER_IDS_FILE: &str = "settings/muted_folder_ids.txt"; +const NOTIFICATIONS_PAUSED_FILE: &str = "settings/notifications_paused.txt"; + +#[tauri::command] +pub async fn get_muted_folder_ids(app: AppHandle) -> Result, String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(MUTED_FOLDER_IDS_FILE); + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + Ok(content + .trim() + .split(',') + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect()) +} + +#[derive(serde::Deserialize)] +pub struct SetMutedFolderIdsParams { + pub folder_ids: Vec, +} + +#[tauri::command] +pub async fn set_muted_folder_ids( + app: AppHandle, + params: SetMutedFolderIdsParams, +) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let settings_dir = app_dir.join("settings"); + std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?; + let content = params + .folder_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + std::fs::write(settings_dir.join("muted_folder_ids.txt"), content) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_notifications_paused(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(NOTIFICATIONS_PAUSED_FILE); + if !path.exists() { + return Ok(false); + } + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + Ok(content.trim() == "true") +} + +#[tauri::command] +pub async fn set_notifications_paused(app: AppHandle, paused: bool) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let settings_dir = app_dir.join("settings"); + std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?; + std::fs::write( + settings_dir.join("notifications_paused.txt"), + if paused { "true" } else { "false" }, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02346f3..b0e3ccb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,10 @@ pub fn run() { commands::vacuum_database, commands::get_orphaned_thumbnails_info, commands::cleanup_orphaned_thumbnails, + commands::get_muted_folder_ids, + commands::set_muted_folder_ids, + commands::get_notifications_paused, + commands::set_notifications_paused, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 84595a2..409da27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,11 +18,15 @@ export default function App() { const loadImages = useGalleryStore((state) => state.loadImages); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); + const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); + const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const activeView = useGalleryStore((state) => state.activeView); useEffect(() => { void initializeNotifications(); + void loadMutedFolderIds(); + void loadNotificationsPaused(); loadFolders().then(() => { void loadBackgroundJobProgress(); void loadCaptionModelStatus(); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index a246357..190344f 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -162,6 +162,8 @@ export function SettingsModal() { const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); + const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); + const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); @@ -601,6 +603,26 @@ export function SettingsModal() { + +
+
+

Pause all notifications

+

Suppress all indexing notifications until re-enabled.

+
+ +
+
+ void; onRename: () => void; onReindex: () => void; onLocate: () => void; + onToggleMute: () => void; onRemove: () => void; }) { const ref = useRef(null); @@ -61,6 +65,7 @@ function FolderContextMenu({ > {item("Reindex", onReindex)} {item("Rename", onRename)} + {item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)} {folder.scan_error && item("Locate Folder", onLocate)}
{item("Remove from app", onRemove, true)} @@ -77,7 +82,9 @@ function FolderItem({ selected: boolean; progress: IndexProgress | undefined; }) { - const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath } = useGalleryStore(); + const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); + const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); + const isMuted = mutedFolderIds.includes(folder.id); const isIndexing = progress && !progress.done; const isMissing = !!folder.scan_error && !isIndexing; @@ -250,10 +257,12 @@ function FolderItem({ setContextMenu(null)} onRename={() => setRenaming(true)} onReindex={() => void reindexFolder(folder.id)} onLocate={() => void handleLocateFolder()} + onToggleMute={() => toggleMutedFolder(folder.id)} onRemove={() => setConfirmingRemoval(true)} /> )} diff --git a/src/store.ts b/src/store.ts index b4a0090..479533d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -4,6 +4,11 @@ import { listen, UnlistenFn } from "@tauri-apps/api/event"; import { appDataDir, join } from "@tauri-apps/api/path"; import { notifyTaskComplete } from "./notifications"; +// Per-folder debounce timers for batching notifications. +// Keyed as `${folderId}:embedding` or `${folderId}:tagging`. +const notificationTimers = new Map>(); +const NOTIFICATION_DEBOUNCE_MS = 6000; + export interface Folder { id: number; path: string; @@ -288,6 +293,8 @@ interface GalleryState { settingsOpen: boolean; taggingQueueScope: TaggingQueueScope; taggingQueueFolderIds: number[]; + mutedFolderIds: number[]; + notificationsPaused: boolean; taggerModelStatus: TaggerModelStatus | null; taggerModelPreparing: boolean; @@ -360,6 +367,10 @@ interface GalleryState { loadTaggingQueueFolderIds: () => Promise; toggleTaggingQueueFolder: (folderId: number) => void; setTaggingQueueFolderIds: (folderIds: number[]) => void; + loadMutedFolderIds: () => Promise; + toggleMutedFolder: (folderId: number) => void; + loadNotificationsPaused: () => Promise; + setNotificationsPaused: (paused: boolean) => void; openAppDataFolder: () => Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; @@ -635,6 +646,8 @@ export const useGalleryStore = create((set, get) => ({ settingsOpen: false, taggingQueueScope: "all", taggingQueueFolderIds: [], + mutedFolderIds: [], + notificationsPaused: false, taggerModelStatus: null, taggerModelPreparing: false, @@ -1371,6 +1384,39 @@ export const useGalleryStore = create((set, get) => ({ void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {}); }, + loadMutedFolderIds: async () => { + try { + const folderIds = await invoke("get_muted_folder_ids"); + set({ mutedFolderIds: folderIds }); + } catch { + // fall back to in-memory default + } + }, + + toggleMutedFolder: (folderId) => { + set((state) => { + const next = state.mutedFolderIds.includes(folderId) + ? state.mutedFolderIds.filter((id) => id !== folderId) + : [...state.mutedFolderIds, folderId]; + void invoke("set_muted_folder_ids", { folder_ids: next }).catch(() => {}); + return { mutedFolderIds: next }; + }); + }, + + loadNotificationsPaused: async () => { + try { + const paused = await invoke("get_notifications_paused"); + set({ notificationsPaused: paused }); + } catch { + // fall back to in-memory default + } + }, + + setNotificationsPaused: (paused) => { + set({ notificationsPaused: paused }); + void invoke("set_notifications_paused", { paused }).catch(() => {}); + }, + openAppDataFolder: async () => { await invoke("open_app_data_folder"); }, @@ -1659,11 +1705,14 @@ export const useGalleryStore = create((set, get) => ({ progress.total > 0 && progress.indexed >= progress.total ) { - const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name; - void notifyTaskComplete( - "Folder scan complete", - folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.", - ); + const { notificationsPaused, mutedFolderIds } = get(); + if (!notificationsPaused && !mutedFolderIds.includes(progress.folder_id)) { + const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name; + void notifyTaskComplete( + "Folder scan complete", + folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.", + ); + } } void get().loadFolders(); void get().loadBackgroundJobProgress(); @@ -1688,32 +1737,52 @@ export const useGalleryStore = create((set, get) => ({ const previous = previousProgress[progress.folder_id]; if (!previous) continue; + const { notificationsPaused, mutedFolderIds } = get(); + const suppressed = notificationsPaused || mutedFolderIds.includes(progress.folder_id); const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder"; - if (previous.embedding_pending > 0 && progress.embedding_pending === 0) { - const failureDetail = - progress.embedding_failed > 0 - ? ` ${progress.embedding_failed.toLocaleString()} failed.` - : ""; - void notifyTaskComplete( - "Embeddings complete", - `${folderName} finished generating embeddings.${failureDetail}`, - ); + // Embeddings — debounced so rapid file additions don't fire per-file. + const embeddingKey = `${progress.folder_id}:embedding`; + if (!suppressed) { + if (previous.embedding_pending > 0 && progress.embedding_pending === 0) { + clearTimeout(notificationTimers.get(embeddingKey)); + const failureDetail = + progress.embedding_failed > 0 + ? ` ${progress.embedding_failed.toLocaleString()} failed.` + : ""; + const body = `${folderName} finished generating embeddings.${failureDetail}`; + notificationTimers.set(embeddingKey, setTimeout(() => { + notificationTimers.delete(embeddingKey); + void notifyTaskComplete("Embeddings complete", body); + }, NOTIFICATION_DEBOUNCE_MS)); + } else if (previous.embedding_pending === 0 && progress.embedding_pending > 0) { + // More jobs queued — cancel the pending notification. + clearTimeout(notificationTimers.get(embeddingKey)); + notificationTimers.delete(embeddingKey); + } } - if (previous.tagging_pending > 0 && progress.tagging_pending === 0) { - const failureDetail = - progress.tagging_failed > 0 - ? ` ${progress.tagging_failed.toLocaleString()} failed.` - : ""; - void notifyTaskComplete( - "AI tagging complete", - `${folderName} finished generating tags.${failureDetail}`, - ); - // New tags are now in the DB — invalidate the Explore tag cache so - // reopening Explore reflects the updated tag distribution. - set({ exploreTagsFolderId: undefined }); + // Tagging — same debounce pattern. + const taggingKey = `${progress.folder_id}:tagging`; + if (!suppressed) { + if (previous.tagging_pending > 0 && progress.tagging_pending === 0) { + clearTimeout(notificationTimers.get(taggingKey)); + const failureDetail = + progress.tagging_failed > 0 + ? ` ${progress.tagging_failed.toLocaleString()} failed.` + : ""; + const body = `${folderName} finished generating tags.${failureDetail}`; + 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)); + } else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) { + clearTimeout(notificationTimers.get(taggingKey)); + notificationTimers.delete(taggingKey); + } } }