From 33fb3c6c7736db8bf13bbea29b8b18624c5bcaaa Mon Sep 17 00:00:00 2001 From: LyAhn Date: Tue, 9 Jun 2026 00:14:49 +0100 Subject: [PATCH 1/4] feat(settings): overhaul Settings modal with improvements and General section - Remove Workers section (read-only status, belongs in background tasks panel) - Remove Captioning coming-soon placeholder - Add General section with Open data folder button (tauri-plugin-opener) - Persist queue scope and folder selection across sessions via settings files - Show inline validation errors on threshold/batch size inputs instead of silent revert - Fix acceleration save errors appearing in wrong card - Disable folder selection controls when target scope is All media --- src-tauri/src/commands.rs | 82 +++++++++++++++++++++ src-tauri/src/lib.rs | 5 ++ src/components/SettingsModal.tsx | 123 +++++++++++++++++++------------ src/store.ts | 32 +++++++- 4 files changed, 192 insertions(+), 50 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1eabb3b..81bca58 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1614,3 +1614,85 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu let conn = db.get().map_err(|e| e.to_string())?; db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string()) } + +// --------------------------------------------------------------------------- +// Queue scope / folder-id persistence +// --------------------------------------------------------------------------- + +const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt"; +const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt"; + +#[derive(Deserialize)] +pub struct SetTaggingQueueScopeParams { + pub scope: String, +} + +#[derive(Deserialize)] +pub struct SetTaggingQueueFolderIdsParams { + pub folder_ids: Vec, +} + +#[tauri::command] +pub async fn get_tagging_queue_scope(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE); + let value = std::fs::read_to_string(path).unwrap_or_default(); + Ok(if value.trim() == "selected" { "selected".to_string() } else { "all".to_string() }) +} + +#[tauri::command] +pub async fn set_tagging_queue_scope( + app: AppHandle, + params: SetTaggingQueueScopeParams, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let value = if params.scope == "selected" { "selected" } else { "all" }; + std::fs::write(path, value).map_err(|e| e.to_string())?; + Ok(value.to_string()) +} + +#[tauri::command] +pub async fn get_tagging_queue_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(TAGGING_QUEUE_FOLDER_IDS_FILE); + let Ok(content) = std::fs::read_to_string(path) else { + return Ok(vec![]); + }; + let ids = content + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + Ok(ids) +} + +#[tauri::command] +pub async fn set_tagging_queue_folder_ids( + app: AppHandle, + params: SetTaggingQueueFolderIdsParams, +) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_FOLDER_IDS_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let content: Vec = params.folder_ids.iter().map(|id| id.to_string()).collect(); + std::fs::write(path, content.join(",")).map_err(|e| e.to_string())?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// App data folder +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + app.opener() + .open_path(app_dir.to_string_lossy().as_ref(), None::<&str>) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bb8a736..38fdd8f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -139,6 +139,11 @@ pub fn run() { commands::delete_images_from_disk, commands::rename_folder, commands::update_folder_path, + commands::get_tagging_queue_scope, + commands::set_tagging_queue_scope, + commands::get_tagging_queue_folder_ids, + commands::set_tagging_queue_folder_ids, + commands::open_app_data_folder, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index d328ac6..2ee06f6 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,11 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store"; -type SettingsSection = "workspace" | "workers"; +type SettingsSection = "workspace" | "general"; const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, - { id: "workers", label: "Workers", detail: "Queue activity and background processing" }, + { id: "general", label: "General", detail: "App data and diagnostics" }, ]; function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { @@ -109,19 +109,29 @@ export function SettingsModal() { const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); + const [taggerAccelerationError, setTaggerAccelerationError] = useState(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); + const [taggerThresholdError, setTaggerThresholdError] = useState(null); const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState(null); const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); + const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null); + const [openingDataFolder, setOpeningDataFolder] = useState(false); + + const thresholdErrorTimerRef = useRef | null>(null); + const batchSizeErrorTimerRef = useRef | null>(null); + const settingsOpen = useGalleryStore((state) => state.settingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const folders = useGalleryStore((state) => state.folders); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope); const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds); + const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope); const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope); const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder); const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds); + const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing); const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress); @@ -145,6 +155,7 @@ export function SettingsModal() { const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); + const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); useEffect(() => { if (!settingsOpen) return; @@ -152,24 +163,29 @@ export function SettingsModal() { void loadTaggerAcceleration(); void loadTaggerThreshold(); void loadTaggerBatchSize(); + void loadTaggingQueueScope(); + void loadTaggingQueueFolderIds(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setSettingsOpen(false); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]); + }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); + + // Clean up error timers on unmount + useEffect(() => { + return () => { + if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); + if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current); + }; + }, []); const selectedFolders = useMemo( () => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)), [folders, taggingQueueFolderIds], ); - const totalQueuedJobs = useMemo( - () => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0), - [mediaJobProgress], - ); - if (!settingsOpen) return null; const taggerReady = taggerModelStatus?.ready ?? false; @@ -268,8 +284,8 @@ export function SettingsModal() {
-

{activeSection === "workspace" ? "AI Workspace" : "Workers"}

-

{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}

+

{activeSection === "workspace" ? "AI Workspace" : "General"}

+

{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}

-

{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}

+ {taggerAccelerationError ? ( +

{taggerAccelerationError}

+ ) : ( +

{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}

+ )} @@ -370,19 +391,27 @@ export function SettingsModal() { onBlur={() => { const value = parseFloat(thresholdDisplay); if (!isNaN(value) && value >= 0.05 && value <= 0.99) { + setTaggerThresholdError(null); setTaggerThresholdSaving(true); void setTaggerThreshold(value) - .catch((error) => setTaggerQueueStatus(String(error))) + .catch((error: unknown) => setTaggerQueueStatus(String(error))) .finally(() => { setTaggerThresholdDraft(null); setTaggerThresholdSaving(false); }); } else { setTaggerThresholdDraft(null); + setTaggerThresholdError("Must be 0.05 – 0.99"); + if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); + thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000); } }} /> -

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+ {taggerThresholdError ? ( +

{taggerThresholdError}

+ ) : ( +

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+ )} @@ -399,6 +428,7 @@ export function SettingsModal() { onBlur={() => { const value = parseInt(batchSizeDisplay, 10); if (!isNaN(value) && value >= 1 && value <= 100) { + setTaggerBatchSizeError(null); setTaggerBatchSizeSaving(true); void setTaggerBatchSize(value) .catch((error: unknown) => setTaggerQueueStatus(String(error))) @@ -408,10 +438,17 @@ export function SettingsModal() { }); } else { setTaggerBatchSizeDraft(null); + setTaggerBatchSizeError("Must be 1 – 100"); + if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current); + batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000); } }} /> -

{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}

+ {taggerBatchSizeError ? ( +

{taggerBatchSizeError}

+ ) : ( +

{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}

+ )} @@ -453,16 +490,16 @@ export function SettingsModal() {
@@ -477,12 +514,13 @@ export function SettingsModal() {
diff --git a/src/store.ts b/src/store.ts index c82494d..85cf5dd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -334,9 +334,12 @@ interface GalleryState { setCaptionDetail: (detail: CaptionDetail) => Promise; setAiCaptionsEnabled: (enabled: boolean) => void; setSettingsOpen: (open: boolean) => void; + loadTaggingQueueScope: () => Promise; setTaggingQueueScope: (scope: TaggingQueueScope) => void; + loadTaggingQueueFolderIds: () => Promise; toggleTaggingQueueFolder: (folderId: number) => void; setTaggingQueueFolderIds: (folderIds: number[]) => void; + openAppDataFolder: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; setCacheDir: (dir: string) => void; @@ -1299,6 +1302,15 @@ export const useGalleryStore = create((set, get) => ({ setSettingsOpen: (settingsOpen) => set({ settingsOpen }), + loadTaggingQueueScope: async () => { + try { + const scope = await invoke("get_tagging_queue_scope"); + set({ taggingQueueScope: scope }); + } catch { + // silently fall back to in-memory default + } + }, + setTaggingQueueScope: (taggingQueueScope) => { set((state) => ({ taggingQueueScope, @@ -1307,6 +1319,16 @@ export const useGalleryStore = create((set, get) => ({ ? [state.folders[0].id] : state.taggingQueueFolderIds, })); + void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {}); + }, + + loadTaggingQueueFolderIds: async () => { + try { + const folderIds = await invoke("get_tagging_queue_folder_ids"); + set({ taggingQueueFolderIds: folderIds }); + } catch { + // silently fall back to in-memory default + } }, toggleTaggingQueueFolder: (folderId) => { @@ -1314,11 +1336,19 @@ export const useGalleryStore = create((set, get) => ({ const next = state.taggingQueueFolderIds.includes(folderId) ? state.taggingQueueFolderIds.filter((id) => id !== folderId) : [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b); + void invoke("set_tagging_queue_folder_ids", { folderIds: next }).catch(() => {}); return { taggingQueueFolderIds: next }; }); }, - setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }), + setTaggingQueueFolderIds: (taggingQueueFolderIds) => { + set({ taggingQueueFolderIds }); + void invoke("set_tagging_queue_folder_ids", { folderIds: taggingQueueFolderIds }).catch(() => {}); + }, + + openAppDataFolder: async () => { + await invoke("open_app_data_folder"); + }, loadTaggerModelStatus: async () => { try { From fbdd43d9d9633ef0d5a810c01fe3c6e5a77a9d59 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Tue, 9 Jun 2026 00:22:30 +0100 Subject: [PATCH 2/4] fix(settings): correct invoke payload keys and error handler routing - folder_ids: use snake_case in both set_tagging_queue_folder_ids invoke calls (toggleTaggingQueueFolder and setTaggingQueueFolderIds) to match the Rust SetTaggingQueueFolderIdsParams serde field name - threshold save error: route setTaggerThreshold() catch to setTaggerThresholdError instead of setTaggerQueueStatus so the error surfaces next to the threshold input, not the queue targets card --- src/components/SettingsModal.tsx | 2 +- src/store.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 2ee06f6..783a9d4 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -394,7 +394,7 @@ export function SettingsModal() { setTaggerThresholdError(null); setTaggerThresholdSaving(true); void setTaggerThreshold(value) - .catch((error: unknown) => setTaggerQueueStatus(String(error))) + .catch((error: unknown) => setTaggerThresholdError(String(error))) .finally(() => { setTaggerThresholdDraft(null); setTaggerThresholdSaving(false); diff --git a/src/store.ts b/src/store.ts index 85cf5dd..8c4b8a1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1336,14 +1336,14 @@ export const useGalleryStore = create((set, get) => ({ const next = state.taggingQueueFolderIds.includes(folderId) ? state.taggingQueueFolderIds.filter((id) => id !== folderId) : [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b); - void invoke("set_tagging_queue_folder_ids", { folderIds: next }).catch(() => {}); + void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {}); return { taggingQueueFolderIds: next }; }); }, setTaggingQueueFolderIds: (taggingQueueFolderIds) => { set({ taggingQueueFolderIds }); - void invoke("set_tagging_queue_folder_ids", { folderIds: taggingQueueFolderIds }).catch(() => {}); + void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {}); }, openAppDataFolder: async () => { From d1eb75a4f52a4ed9cd9007f1b91586c64e521840 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Tue, 9 Jun 2026 00:40:05 +0100 Subject: [PATCH 3/4] feat(settings): add compact database card to General section Adds get_database_info and vacuum_database Tauri commands. The General section now shows current DB size and reclaimable space on load, with a Compact now button that runs PRAGMA wal_checkpoint(FULL) + VACUUM and reports how many MB were freed. Button disables automatically when the database is already compact (< 0.5 MB reclaimable). --- src-tauri/src/commands.rs | 51 +++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/components/SettingsModal.tsx | 69 +++++++++++++++++++++++++++++++- src/store.ts | 17 ++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 81bca58..78ad8e4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1696,3 +1696,54 @@ pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> { .open_path(app_dir.to_string_lossy().as_ref(), None::<&str>) .map_err(|e| e.to_string()) } + +// --------------------------------------------------------------------------- +// Database maintenance +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct DatabaseInfo { + pub size_mb: f64, + pub reclaimable_mb: f64, +} + +#[derive(Serialize)] +pub struct VacuumResult { + pub before_mb: f64, + pub after_mb: f64, + pub freed_mb: f64, +} + +#[tauri::command] +pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = app_dir.join("gallery.db"); + let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + + let conn = db.get().map_err(|e| e.to_string())?; + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0)).map_err(|e| e.to_string())?; + let freelist_count: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0)).map_err(|e| e.to_string())?; + + Ok(DatabaseInfo { + size_mb: size_bytes as f64 / 1_048_576.0, + reclaimable_mb: (freelist_count * page_size) as f64 / 1_048_576.0, + }) +} + +#[tauri::command] +pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = app_dir.join("gallery.db"); + let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + + let conn = db.get().map_err(|e| e.to_string())?; + conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;").map_err(|e| e.to_string())?; + drop(conn); + + let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + Ok(VacuumResult { + before_mb: before_bytes as f64 / 1_048_576.0, + after_mb: after_bytes as f64 / 1_048_576.0, + freed_mb: before_bytes.saturating_sub(after_bytes) as f64 / 1_048_576.0, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38fdd8f..01c05b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,6 +144,8 @@ pub fn run() { commands::get_tagging_queue_folder_ids, commands::set_tagging_queue_folder_ids, commands::open_app_data_folder, + commands::get_database_info, + commands::vacuum_database, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 783a9d4..9ec06a4 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store"; +import { DatabaseInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; type SettingsSection = "workspace" | "general"; @@ -117,6 +117,9 @@ export function SettingsModal() { const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null); const [openingDataFolder, setOpeningDataFolder] = useState(false); + const [dbInfo, setDbInfo] = useState(null); + const [vacuuming, setVacuuming] = useState(false); + const [vacuumResult, setVacuumResult] = useState(null); const thresholdErrorTimerRef = useRef | null>(null); const batchSizeErrorTimerRef = useRef | null>(null); @@ -156,6 +159,8 @@ export function SettingsModal() { const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); + const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); + const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); useEffect(() => { if (!settingsOpen) return; @@ -173,6 +178,12 @@ export function SettingsModal() { return () => window.removeEventListener("keydown", handleKeyDown); }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); + useEffect(() => { + if (!settingsOpen || activeSection !== "general") return; + setVacuumResult(null); + void getDatabaseInfo().then(setDbInfo).catch(() => {}); + }, [settingsOpen, activeSection, getDatabaseInfo]); + // Clean up error timers on unmount useEffect(() => { return () => { @@ -582,6 +593,62 @@ export function SettingsModal() { + + +
+
+
+

Database size

+

+ {vacuumResult + ? `${vacuumResult.after_mb.toFixed(1)} MB` + : dbInfo + ? `${dbInfo.size_mb.toFixed(1)} MB` + : "—"} +

+
+
+

Reclaimable

+

+ {vacuumResult + ? `−${vacuumResult.freed_mb.toFixed(1)} MB freed` + : dbInfo + ? `${dbInfo.reclaimable_mb.toFixed(1)} MB` + : "—"} +

+
+
+
+

+ {vacuumResult + ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.` + : dbInfo && dbInfo.reclaimable_mb < 0.5 + ? "Database is already compact." + : "Run this after removing folders or bulk-deleting images."} +

+ +
+
+
)} diff --git a/src/store.ts b/src/store.ts index 8c4b8a1..298655d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -71,6 +71,17 @@ export interface ImageTag { created_at: string; } +export interface DatabaseInfo { + size_mb: number; + reclaimable_mb: number; +} + +export interface VacuumResult { + before_mb: number; + after_mb: number; + freed_mb: number; +} + export interface TaggerModelStatus { model_id: string; model_name: string; @@ -340,6 +351,8 @@ interface GalleryState { toggleTaggingQueueFolder: (folderId: number) => void; setTaggingQueueFolderIds: (folderIds: number[]) => void; openAppDataFolder: () => Promise; + getDatabaseInfo: () => Promise; + vacuumDatabase: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; setCacheDir: (dir: string) => void; @@ -1350,6 +1363,10 @@ export const useGalleryStore = create((set, get) => ({ await invoke("open_app_data_folder"); }, + getDatabaseInfo: () => invoke("get_database_info"), + + vacuumDatabase: () => invoke("vacuum_database"), + loadTaggerModelStatus: async () => { try { const taggerModelStatus = await invoke("get_tagger_model_status"); From 3707a35cc4fef3aa4da0878a3a73256c022f4a77 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Tue, 9 Jun 2026 01:09:29 +0100 Subject: [PATCH 4/4] feat(settings): add orphaned thumbnail cleanup to General section Scans the thumbnails directory and cross-references against thumbnail_path values in the images table, deleting any files not linked to an indexed image. Surfaces count and reclaimable MB in the General section alongside the existing Compact database card. --- src-tauri/src/commands.rs | 97 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/components/SettingsModal.tsx | 66 +++++++++++++++++++++- src/store.ts | 16 ++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 78ad8e4..7be9948 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1747,3 +1747,100 @@ pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result Result, String> { + let mut stmt = conn + .prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL") + .map_err(|e| e.to_string())?; + let set = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .filter_map(|p| { + std::path::Path::new(&p) + .file_name() + .and_then(|f| f.to_str()) + .map(|s| s.to_owned()) + }) + .collect(); + Ok(set) +} + +#[tauri::command] +pub async fn get_orphaned_thumbnails_info( + app: AppHandle, + db: State<'_, DbState>, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let thumb_dir = app_dir.join("thumbnails"); + + let conn = db.get().map_err(|e| e.to_string())?; + let db_filenames = collect_db_thumbnail_filenames(&conn)?; + drop(conn); + + let mut count = 0u64; + let mut size_bytes = 0u64; + + if thumb_dir.exists() { + for entry in std::fs::read_dir(&thumb_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let fname = entry.file_name(); + if !db_filenames.contains(fname.to_string_lossy().as_ref()) { + size_bytes += entry.metadata().map(|m| m.len()).unwrap_or(0); + count += 1; + } + } + } + + Ok(OrphanedThumbnailsInfo { + count, + size_mb: size_bytes as f64 / 1_048_576.0, + }) +} + +#[tauri::command] +pub async fn cleanup_orphaned_thumbnails( + app: AppHandle, + db: State<'_, DbState>, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let thumb_dir = app_dir.join("thumbnails"); + + let conn = db.get().map_err(|e| e.to_string())?; + let db_filenames = collect_db_thumbnail_filenames(&conn)?; + drop(conn); + + let mut deleted_count = 0u64; + let mut freed_bytes = 0u64; + + if thumb_dir.exists() { + for entry in std::fs::read_dir(&thumb_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let fname = entry.file_name(); + if !db_filenames.contains(fname.to_string_lossy().as_ref()) { + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + if std::fs::remove_file(entry.path()).is_ok() { + deleted_count += 1; + freed_bytes += size; + } + } + } + } + + Ok(CleanupOrphanedThumbnailsResult { + deleted_count, + freed_mb: freed_bytes as f64 / 1_048_576.0, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01c05b8..00ba11a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,6 +146,8 @@ pub fn run() { commands::open_app_data_folder, commands::get_database_info, commands::vacuum_database, + commands::get_orphaned_thumbnails_info, + commands::cleanup_orphaned_thumbnails, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 9ec06a4..a00accc 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { DatabaseInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; type SettingsSection = "workspace" | "general"; @@ -120,6 +120,9 @@ export function SettingsModal() { const [dbInfo, setDbInfo] = useState(null); const [vacuuming, setVacuuming] = useState(false); const [vacuumResult, setVacuumResult] = useState(null); + const [thumbnailInfo, setThumbnailInfo] = useState(null); + const [cleaningThumbnails, setCleaningThumbnails] = useState(false); + const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null); const thresholdErrorTimerRef = useRef | null>(null); const batchSizeErrorTimerRef = useRef | null>(null); @@ -161,6 +164,8 @@ export function SettingsModal() { const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); + const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); + const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); useEffect(() => { if (!settingsOpen) return; @@ -181,8 +186,10 @@ export function SettingsModal() { useEffect(() => { if (!settingsOpen || activeSection !== "general") return; setVacuumResult(null); + setThumbnailCleanupResult(null); void getDatabaseInfo().then(setDbInfo).catch(() => {}); - }, [settingsOpen, activeSection, getDatabaseInfo]); + void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {}); + }, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]); // Clean up error timers on unmount useEffect(() => { @@ -649,6 +656,61 @@ export function SettingsModal() { + + +
+
+
+

Orphaned files

+

+ {thumbnailCleanupResult + ? thumbnailCleanupResult.deleted_count.toLocaleString() + : thumbnailInfo + ? thumbnailInfo.count.toLocaleString() + : "—"} +

+
+
+

Reclaimable

+

+ {thumbnailCleanupResult + ? `−${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed` + : thumbnailInfo + ? `${thumbnailInfo.size_mb.toFixed(1)} MB` + : "—"} +

+
+
+
+

+ {thumbnailCleanupResult + ? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} orphaned thumbnail${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}.` + : thumbnailInfo && thumbnailInfo.count === 0 + ? "No orphaned thumbnails found." + : "Remove thumbnails no longer associated with any indexed image."} +

+ +
+
+
)} diff --git a/src/store.ts b/src/store.ts index 298655d..b4a0090 100644 --- a/src/store.ts +++ b/src/store.ts @@ -82,6 +82,16 @@ export interface VacuumResult { freed_mb: number; } +export interface OrphanedThumbnailsInfo { + count: number; + size_mb: number; +} + +export interface CleanupOrphanedThumbnailsResult { + deleted_count: number; + freed_mb: number; +} + export interface TaggerModelStatus { model_id: string; model_name: string; @@ -353,6 +363,8 @@ interface GalleryState { openAppDataFolder: () => Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; + getOrphanedThumbnailsInfo: () => Promise; + cleanupOrphanedThumbnails: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; setCacheDir: (dir: string) => void; @@ -1367,6 +1379,10 @@ export const useGalleryStore = create((set, get) => ({ vacuumDatabase: () => invoke("vacuum_database"), + getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), + + cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"), + loadTaggerModelStatus: async () => { try { const taggerModelStatus = await invoke("get_tagger_model_status");