From 3db95a44899cdff73496b691f8bea27d03ce6ae3 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 13:45:11 +0100 Subject: [PATCH 1/5] perf: virtualize the Duplicate Finder group list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The duplicate view rendered every group and every thumbnail at once — a 5,000-pair result mounted ~10K elements, making scroll lag heavily (same class of bug as the old per-month Timeline). Virtualize the group list so only on-screen cards mount; heights are measured dynamically since each group wraps a variable number of copies. --- src/components/DuplicateFinder.tsx | 42 +++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 1ab85fe..7824a49 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { convertFileSrc } from "@tauri-apps/api/core"; import { DuplicateGroup, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; @@ -130,6 +131,17 @@ export function DuplicateFinder() { const [deleting, setDeleting] = useState(false); const [deleteResult, setDeleteResult] = useState(null); + // Virtualize the group list so a large result set (e.g. thousands of pairs) + // only mounts the on-screen cards. Group cards vary in height (number of + // copies wraps across rows), so heights are measured dynamically. + const scrollRef = useRef(null); + const virtualizer = useVirtualizer({ + count: duplicateGroups.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 220, + overscan: 4, + }); + const selectedCount = duplicateSelectedIds.size; const hasResults = duplicateGroups.length > 0; const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null); @@ -277,11 +289,29 @@ export function DuplicateFinder() {

No duplicate files found.

) : ( -
-
- {duplicateGroups.map((group) => ( - - ))} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const group = duplicateGroups[virtualItem.index]; + if (!group) return null; + return ( +
+ +
+ ); + })}
)} From 587020504731048741459983c26d1edc7bb53b82 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 14:39:41 +0100 Subject: [PATCH 2/5] feat(settings): General-first layout + lightbox video playback toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder the Settings sections so General is the top and default section instead of AI Workspace. Add two persisted settings under a new "Video playback" group: - Autoplay in lightbox (default on) - Start muted (default off) VideoPlayer reads these when a video opens — autoplaying only when enabled and starting muted when enabled, otherwise falling back to the session's last-used mute state. Settings apply to the next opened video, not the current one. --- src/components/SettingsModal.tsx | 37 ++++++++++++++++++++++++++++++-- src/components/VideoPlayer.tsx | 18 +++++++++++----- src/store.ts | 24 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 52e42d4..8393c99 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -6,8 +6,8 @@ import { ThemedDropdown } from "./ThemedDropdown"; type SettingsSection = "workspace" | "general"; const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ - { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, { id: "general", label: "General", detail: "App data and diagnostics" }, + { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, ]; function formatBytesShort(bytes: number): string { @@ -122,7 +122,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }: } export function SettingsModal() { - const [activeSection, setActiveSection] = useState("workspace"); + const [activeSection, setActiveSection] = useState("general"); const [taggerQueueStatus, setTaggerQueueStatus] = useState(null); const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false); @@ -195,6 +195,10 @@ export function SettingsModal() { const openOnboarding = useGalleryStore((state) => state.openOnboarding); const theme = useGalleryStore((state) => state.theme); const setTheme = useGalleryStore((state) => state.setTheme); + const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay); + const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay); + const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute); + const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute); useEffect(() => { if (!settingsOpen) return; @@ -618,6 +622,35 @@ export function SettingsModal() { + + + + + + + + + ([]); const [volume, setVolume] = useState(persistedVolume); - const [muted, setMuted] = useState(persistedMuted); + const [muted, setMuted] = useState( + () => useGalleryStore.getState().lightboxAutoMute || persistedMuted, + ); const [playbackRate, setPlaybackRate] = useState(1); const [loop, setLoop] = useState(false); const [fullscreen, setFullscreen] = useState(false); @@ -89,11 +92,16 @@ export function VideoPlayer({ src }: { src: string }) { useEffect(() => { const video = videoRef.current; if (!video) return; + // Read playback prefs fresh for each opened video — not reactive, so a + // settings change applies to the next video rather than the current one. + const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState(); + const startMuted = lightboxAutoMute || persistedMuted; video.volume = persistedVolume; - video.muted = persistedMuted; - // Autoplay; if the webview blocks it the catch leaves us paused with - // controls visible, which is a fine fallback. - video.play().catch(() => {}); + video.muted = startMuted; + setMuted(startMuted); + // Autoplay when enabled; if the webview blocks it the catch leaves us paused + // with controls visible, which is a fine fallback. + if (lightboxAutoplay) video.play().catch(() => {}); }, [src]); const readBuffered = useCallback(() => { diff --git a/src/store.ts b/src/store.ts index 2daca96..e08f483 100644 --- a/src/store.ts +++ b/src/store.ts @@ -12,6 +12,8 @@ import { notifyTaskComplete } from "./notifications"; const notificationTimers = new Map>(); const NOTIFICATION_DEBOUNCE_MS = 6000; const THEME_KEY = "phokus-theme"; +const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay"; +const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute"; export interface Folder { id: number; @@ -346,6 +348,8 @@ interface GalleryState { mutedFolderIds: number[]; notificationsPaused: boolean; theme: AppTheme; + lightboxAutoplay: boolean; + lightboxAutoMute: boolean; // Per-folder background-worker pause flags, shared by the BackgroundTasks // bar and the sidebar folder context menu. workerPaused: Record>; @@ -445,6 +449,8 @@ interface GalleryState { loadNotificationsPaused: () => Promise; setNotificationsPaused: (paused: boolean) => void; setTheme: (theme: AppTheme) => void; + setLightboxAutoplay: (enabled: boolean) => void; + setLightboxAutoMute: (enabled: boolean) => void; loadWorkerStates: () => Promise; setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void; setAllWorkersPaused: (folderId: number, paused: boolean) => void; @@ -515,6 +521,12 @@ function initialAiCaptionsEnabled(): boolean { return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true"; } +function initialBoolSetting(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") return fallback; + const stored = window.localStorage.getItem(key); + return stored === null ? fallback : stored === "true"; +} + function initialTheme(): AppTheme { if (typeof window === "undefined") return "phokus"; const saved = window.localStorage.getItem(THEME_KEY); @@ -765,6 +777,8 @@ export const useGalleryStore = create((set, get) => ({ mutedFolderIds: [], notificationsPaused: false, theme: initialTheme(), + lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), + lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), workerPaused: {}, appVersion: null, @@ -1649,6 +1663,16 @@ export const useGalleryStore = create((set, get) => ({ set({ theme }); }, + setLightboxAutoplay: (enabled) => { + window.localStorage.setItem(LIGHTBOX_AUTOPLAY_KEY, String(enabled)); + set({ lightboxAutoplay: enabled }); + }, + + setLightboxAutoMute: (enabled) => { + window.localStorage.setItem(LIGHTBOX_AUTO_MUTE_KEY, String(enabled)); + set({ lightboxAutoMute: enabled }); + }, + loadWorkerStates: async () => { const folderIds = get().folders.map((folder) => folder.id); if (folderIds.length === 0) { From f66fbe79310283322e18d3f3961e178fe497686a Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 15:17:34 +0100 Subject: [PATCH 3/5] feat(settings): add "Rebuild semantic index" maintenance action Drops and recreates the sqlite-vec tables at the current model dimension, then re-queues every image for embedding. Fixes "dimension mismatch" search errors that occur when the vector table was built for a different model/dimension (e.g. after experimenting with 768-dim models against this 512-dim build). The rebuild runs under the embedding worker's DB write lock and resets the job queue inside a single transaction, so it can't interleave with an in-flight embedding batch (per code review). --- src-tauri/src/commands.rs | 8 +++++++ src-tauri/src/db.rs | 35 ++++++++++++++++++++++++++++ src-tauri/src/indexer.rs | 2 +- src-tauri/src/lib.rs | 1 + src-tauri/src/vector.rs | 12 ++++++++++ src/components/SettingsModal.tsx | 39 ++++++++++++++++++++++++++++++++ src/store.ts | 3 +++ 7 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index de4f768..901283f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1987,6 +1987,14 @@ pub async fn vacuum_database( }) } +#[tauri::command] +pub async fn rebuild_semantic_index(db: State<'_, DbState>) -> Result { + let conn = db.get().map_err(|e| e.to_string())?; + // Serialize against the embedding worker (which writes under the same lock) + // so the rebuild can't interleave with an in-flight embedding batch. + indexer::with_db_write_lock(|| db::reset_all_embeddings(&conn)).map_err(|e| e.to_string()) +} + #[derive(serde::Serialize)] pub struct OrphanedThumbnailsInfo { pub count: u64, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ccb8884..cdcb7bf 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -496,6 +496,41 @@ pub fn repair_embedding_consistency(conn: &Connection) -> Result<(usize, usize)> Ok((orphaned_vectors, missing_vector_ids.len())) } +/// Full semantic-index rebuild: recreate the vector tables at the current model +/// dimension and re-queue every image for embedding. Used by the maintenance +/// action when the index is stale or its dimension no longer matches the model +/// (which otherwise surfaces as a "dimension mismatch" search error). Returns the +/// number of images re-queued. +pub fn reset_all_embeddings(conn: &Connection) -> Result { + // Recreate the vector tables at the current model dimension first (DDL, kept + // out of the transaction below). The caller holds the DB write lock, so the + // embedding worker can't interleave a write between this and the queue reset. + vector::rebuild_tables(conn)?; + let tx = conn.unchecked_transaction()?; + tx.execute( + "UPDATE images + SET embedding_status = 'pending', + embedding_model = NULL, + embedding_updated_at = NULL, + embedding_error = NULL", + [], + )?; + tx.execute("DELETE FROM embedding_jobs", [])?; + let queued = tx.execute( + "INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at) + SELECT id, 'pending', 0, NULL, datetime('now'), datetime('now') + FROM images", + [], + )?; + tx.execute( + "INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1) + ON CONFLICT(key) DO UPDATE SET value = value + 1", + [], + )?; + tx.commit()?; + Ok(queued) +} + pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result { // Only re-queue images that are actually embeddable right now. // Videos without a thumbnail would just fail again immediately, so skip them — diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 4779ca6..244ffe2 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -1288,7 +1288,7 @@ fn max_worker_fetch_size(active_folders: &HashSet) -> usize { .unwrap_or(StorageProfile::Balanced.worker_fetch_size()) } -fn with_db_write_lock(operation: impl FnOnce() -> Result) -> Result { +pub fn with_db_write_lock(operation: impl FnOnce() -> Result) -> Result { let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(())); let _guard = lock.lock().unwrap(); operation() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79a5379..5f6cade 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -192,6 +192,7 @@ pub fn run() { commands::open_app_data_folder, commands::get_database_info, commands::vacuum_database, + commands::rebuild_semantic_index, commands::get_orphaned_thumbnails_info, commands::cleanup_orphaned_thumbnails, commands::get_muted_folder_ids, diff --git a/src-tauri/src/vector.rs b/src-tauri/src/vector.rs index c32d22c..f6b5545 100644 --- a/src-tauri/src/vector.rs +++ b/src-tauri/src/vector.rs @@ -36,6 +36,18 @@ pub fn migrate(conn: &Connection) -> Result<()> { Ok(()) } +/// Drop and recreate the vector tables at the current `CLIP_VECTOR_DIM`. Used by +/// the "Rebuild semantic index" maintenance action when stored vectors no longer +/// match the active model's dimension (e.g. after switching embedding models), +/// so the columns are rebuilt to the right size before embeddings regenerate. +pub fn rebuild_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + "DROP TABLE IF EXISTS image_vec; + DROP TABLE IF EXISTS caption_vec;", + )?; + migrate(conn) +} + #[allow(dead_code)] pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> { conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 8393c99..ba50dca 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -138,6 +138,8 @@ export function SettingsModal() { const [dbInfo, setDbInfo] = useState(null); const [vacuuming, setVacuuming] = useState(false); const [vacuumResult, setVacuumResult] = useState(null); + const [rebuildingIndex, setRebuildingIndex] = useState(false); + const [rebuildIndexResult, setRebuildIndexResult] = useState(null); const [thumbnailInfo, setThumbnailInfo] = useState(null); const [cleaningThumbnails, setCleaningThumbnails] = useState(false); const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null); @@ -184,6 +186,7 @@ export function SettingsModal() { const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); + const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex); const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); const appVersion = useGalleryStore((state) => state.appVersion); @@ -799,6 +802,42 @@ export function SettingsModal() { + + + Recreates the visual-embedding index and re-embeds every image in the + background. Use this if semantic or similar-image search reports a + dimension-mismatch error (for example after experimenting with a + different embedding model). + + {rebuildIndexResult !== null ? ( + {rebuildIndexResult} + ) : null} + + } + > + + + Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; + rebuildSemanticIndex: () => Promise; getOrphanedThumbnailsInfo: () => Promise; cleanupOrphanedThumbnails: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; @@ -1858,6 +1859,8 @@ export const useGalleryStore = create((set, get) => ({ vacuumDatabase: () => invoke("vacuum_database"), + rebuildSemanticIndex: () => invoke("rebuild_semantic_index"), + getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"), From 74a4134f2f79a35c11e71e4cecd63c0f2e9d079d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 17:38:01 +0100 Subject: [PATCH 4/5] feat: add custom multi-folder picker Replace native add-folder dialogs with an in-app folder picker that supports collecting folders from multiple locations before adding them together. Add backend directory listing and batch add commands with duplicate skipping, plus store actions and a themed picker UI with a dedicated folders-to-add panel. --- src-tauri/src/commands.rs | 232 +++++++++- src-tauri/src/lib.rs | 2 + src/App.tsx | 2 + src/components/FolderPickerModal.tsx | 448 +++++++++++++++++++ src/components/MenuBar.tsx | 10 +- src/components/Sidebar.tsx | 9 +- src/components/onboarding/StepAddLibrary.tsx | 26 +- src/store.ts | 33 ++ 8 files changed, 720 insertions(+), 42 deletions(-) create mode 100644 src/components/FolderPickerModal.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 901283f..974a32a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,7 +9,8 @@ use crate::indexer::{self, WatcherHandle}; use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::vector; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use tauri::{AppHandle, Emitter, Manager, State}; pub type DbState = DbPool; @@ -215,19 +216,63 @@ pub struct GetImagesByIdsParams { pub image_ids: Vec, } -#[tauri::command] -pub async fn add_folder( +#[derive(Serialize)] +#[serde(tag = "status", content = "data", rename_all = "camelCase")] +pub enum FolderAddResult { + Added(Folder), + Skipped(String), + Error(String), +} + +enum AddOutcome { + Added(Folder), + Skipped(Folder), +} + +fn paths_match(left: &str, right: &str) -> bool { + let left_path = PathBuf::from(left); + let right_path = PathBuf::from(right); + if let (Ok(left_canonical), Ok(right_canonical)) = + (left_path.canonicalize(), right_path.canonicalize()) + { + return left_canonical == right_canonical; + } + + #[cfg(windows)] + { + left.trim_end_matches(['\\', '/']) + .eq_ignore_ascii_case(right.trim_end_matches(['\\', '/'])) + } + + #[cfg(not(windows))] + { + left.trim_end_matches('/').eq(right.trim_end_matches('/')) + } +} + +fn add_one_folder( app: AppHandle, - db: State<'_, DbState>, - watcher: State<'_, WatcherHandle>, + db: DbPool, + watcher: WatcherHandle, path: String, -) -> Result { +) -> Result { let folder_path = PathBuf::from(&path); if !folder_path.exists() || !folder_path.is_dir() { return Err("Path is not a valid directory".into()); } + { + let conn = db.get().map_err(|e| e.to_string())?; + if let Some(folder) = db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .find(|folder| paths_match(&folder.path, &path)) + { + return Ok(AddOutcome::Skipped(folder)); + } + } + // Let the webview load media from this folder via the asset protocol. if let Err(error) = app .asset_protocol_scope() @@ -257,9 +302,41 @@ pub async fn add_folder( .ok_or("Folder not found after insert")?; watcher.add_folder(folder_path.clone(), folder_id); - indexer::index_folder(app, db.inner().clone(), folder_id, folder_path); + indexer::index_folder(app, db, folder_id, folder_path); - Ok(folder) + Ok(AddOutcome::Added(folder)) +} + +#[tauri::command] +pub async fn add_folder( + app: AppHandle, + db: State<'_, DbState>, + watcher: State<'_, WatcherHandle>, + path: String, +) -> Result { + match add_one_folder(app, db.inner().clone(), watcher.inner().clone(), path)? { + AddOutcome::Added(folder) => Ok(folder), + AddOutcome::Skipped(folder) => Ok(folder), + } +} + +#[tauri::command] +pub async fn add_folders( + app: AppHandle, + db: State<'_, DbState>, + watcher: State<'_, WatcherHandle>, + paths: Vec, +) -> Result, String> { + Ok(paths + .into_iter() + .map(|path| { + match add_one_folder(app.clone(), db.inner().clone(), watcher.inner().clone(), path) { + Ok(AddOutcome::Added(folder)) => FolderAddResult::Added(folder), + Ok(AddOutcome::Skipped(folder)) => FolderAddResult::Skipped(folder.path), + Err(error) => FolderAddResult::Error(error), + } + }) + .collect()) } #[tauri::command] @@ -268,6 +345,145 @@ pub async fn get_folders(db: State<'_, DbState>) -> Result, String> db::get_folders(&conn).map_err(|e| e.to_string()) } +#[derive(Serialize)] +pub struct DirListing { + pub current: Option, + pub parent: Option, + pub entries: Vec, +} + +#[derive(Serialize)] +pub struct DirEntry { + pub name: String, + pub path: String, + pub has_children: bool, +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +fn directory_has_children(path: &Path) -> bool { + std::fs::read_dir(path) + .map(|mut entries| entries.any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_type().ok()) + .map(|file_type| file_type.is_dir()) + .unwrap_or(false) + })) + .unwrap_or(false) +} + +fn common_root_candidates() -> Vec { + let mut roots = Vec::new(); + + #[cfg(windows)] + { + for letter in b'A'..=b'Z' { + let drive = format!("{}:\\", letter as char); + let path = PathBuf::from(&drive); + if path.exists() { + roots.push(path); + } + } + + if let Ok(profile) = std::env::var("USERPROFILE") { + let home = PathBuf::from(profile); + roots.push(home.clone()); + for child in ["Pictures", "Videos", "Desktop", "Downloads"] { + roots.push(home.join(child)); + } + } + } + + #[cfg(not(windows))] + { + if let Ok(home) = std::env::var("HOME") { + roots.push(PathBuf::from(home)); + } + roots.push(PathBuf::from("/")); + } + + roots +} + +fn list_roots() -> Vec { + let mut seen = HashSet::new(); + let mut entries: Vec = common_root_candidates() + .into_iter() + .filter(|path| path.exists() && path.is_dir()) + .filter_map(|path| { + let path_string = path_to_string(&path); + if !seen.insert(path_string.clone()) { + return None; + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| path_string.clone()); + Some(DirEntry { + name, + path: path_string, + has_children: directory_has_children(&path), + }) + }) + .collect(); + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + entries +} + +fn list_child_directories(path: &Path) -> Result, String> { + let mut entries = Vec::new(); + let read_dir = std::fs::read_dir(path).map_err(|e| e.to_string())?; + + for entry in read_dir.flatten() { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + + let child_path = entry.path(); + let is_dir = entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false); + if !is_dir { + continue; + } + + entries.push(DirEntry { + name, + path: path_to_string(&child_path), + has_children: directory_has_children(&child_path), + }); + } + + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(entries) +} + +#[tauri::command] +pub async fn list_directories(path: Option) -> Result { + let trimmed = path.as_deref().map(str::trim).unwrap_or(""); + if trimmed.is_empty() { + return Ok(DirListing { + current: None, + parent: None, + entries: list_roots(), + }); + } + + let current_path = PathBuf::from(trimmed); + let parent = current_path.parent().map(path_to_string); + let entries = list_child_directories(¤t_path)?; + + Ok(DirListing { + current: Some(path_to_string(¤t_path)), + parent, + entries, + }) +} + #[derive(Deserialize)] pub struct ReorderFoldersParams { pub folder_ids: Vec, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f6cade..531c00a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -128,6 +128,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::add_folder, + commands::add_folders, + commands::list_directories, commands::get_folders, commands::reorder_folders, commands::get_background_job_progress, diff --git a/src/App.tsx b/src/App.tsx index 4c97748..fa0adb3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { DuplicateFinder } from "./components/DuplicateFinder"; import { Timeline } from "./components/Timeline"; import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; +import { FolderPickerModal } from "./components/FolderPickerModal"; import { UpdateToast } from "./components/UpdateToast"; import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; import { DemoPanel } from "./components/DemoPanel"; @@ -93,6 +94,7 @@ export default function App() { + {import.meta.env.DEV && } diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx new file mode 100644 index 0000000..c509993 --- /dev/null +++ b/src/components/FolderPickerModal.tsx @@ -0,0 +1,448 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); +} + +function folderName(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + const parts = trimmed.split(/[\\/]+/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : path; +} + +function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] { + if (!path) return [{ label: "This PC / Home", path: null }]; + + const separator = path.includes("\\") ? "\\" : "/"; + const normalized = path.replace(/[\\/]+$/, ""); + const windowsDrive = normalized.match(/^[A-Za-z]:/); + + if (windowsDrive) { + const drive = windowsDrive[0] + "\\"; + const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean); + let current = drive; + return [ + { label: "This PC", path: null }, + { label: drive, path: drive }, + ...rest.map((part) => { + current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`; + return { label: part, path: current }; + }), + ]; + } + + const parts = normalized.split(/[\\/]+/).filter(Boolean); + let current = separator === "/" ? "" : ""; + return [ + { label: "Home", path: null }, + ...parts.map((part) => { + current = `${current}/${part}`; + return { label: part, path: current }; + }), + ]; +} + +function StatusLine({ results }: { results: FolderAddResult[] | null }) { + if (!results) return null; + const added = results.filter((result) => result.status === "added").length; + const skipped = results.filter((result) => result.status === "skipped").length; + const failed = results.filter((result) => result.status === "error").length; + return ( +

+ Added {added}, skipped {skipped}, failed {failed}. +

+ ); +} + +function FolderRow({ + entry, + selected, + alreadyAdded, + onToggle, + onNavigate, +}: { + entry: DirEntry; + selected: boolean; + alreadyAdded: boolean; + onToggle: () => void; + onNavigate: () => void; +}) { + return ( +
+ + + + + {alreadyAdded ? ( + + Added + + ) : null} + + +
+ ); +} + +function StagedFoldersPanel({ + stagedPaths, + onRemove, + onClear, +}: { + stagedPaths: string[]; + onRemove: (path: string) => void; + onClear: () => void; +}) { + return ( + + ); +} + +export function FolderPickerModal() { + const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen); + const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); + const folders = useGalleryStore((state) => state.folders); + const listDirectories = useGalleryStore((state) => state.listDirectories); + const addFolders = useGalleryStore((state) => state.addFolders); + + const [listing, setListing] = useState(null); + const [currentPath, setCurrentPath] = useState(null); + const [stagedPaths, setStagedPaths] = useState([]); + const [loading, setLoading] = useState(false); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState(null); + const scrollRef = useRef(null); + + const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]); + const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]); + const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]); + + const virtualizer = useVirtualizer({ + count: listing?.entries.length ?? 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => 48, + overscan: 8, + }); + + useEffect(() => { + if (!folderPickerOpen) return; + let cancelled = false; + setLoading(true); + setError(null); + void listDirectories(currentPath) + .then((nextListing) => { + if (cancelled) return; + setListing(nextListing); + scrollRef.current?.scrollTo({ top: 0, left: 0 }); + }) + .catch((loadError) => { + if (cancelled) return; + setListing({ current: currentPath, parent: null, entries: [] }); + setError(loadError instanceof Error ? loadError.message : String(loadError)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [currentPath, folderPickerOpen, listDirectories]); + + useEffect(() => { + if (!folderPickerOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setFolderPickerOpen(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [folderPickerOpen, setFolderPickerOpen]); + + useEffect(() => { + if (folderPickerOpen) return; + setCurrentPath(null); + setListing(null); + setStagedPaths([]); + setError(null); + setResults(null); + setAdding(false); + }, [folderPickerOpen]); + + if (!folderPickerOpen) return null; + + const entries = listing?.entries ?? []; + + const togglePath = (path: string) => { + const normalized = normalizePath(path); + if (libraryPaths.has(normalized)) return; + setResults(null); + setStagedPaths((current) => { + const exists = current.some((staged) => normalizePath(staged) === normalized); + return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path]; + }); + }; + + const removeStagedPath = (path: string) => { + const normalized = normalizePath(path); + setResults(null); + setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized)); + }; + + const clearStagedPaths = () => { + setResults(null); + setStagedPaths([]); + }; + + const confirmAdd = async () => { + if (stagedPaths.length === 0 || adding) return; + setAdding(true); + setError(null); + try { + const addResults = await addFolders(stagedPaths); + const failed = addResults.filter((result) => result.status === "error"); + setResults(addResults); + if (failed.length > 0) { + setError(failed.map((failure) => failure.data).join("; ")); + return; + } + setFolderPickerOpen(false); + } catch (addError) { + setError(addError instanceof Error ? addError.message : String(addError)); + } finally { + setAdding(false); + } + }; + + return ( +
setFolderPickerOpen(false)} + > +
event.stopPropagation()} + > +
+
+
+

Add media folders

+

Choose folders from any location, then add them together.

+
+ +
+
+ +
+
+
+ + +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ {loading ? ( +
Loading folders...
+ ) : entries.length === 0 ? ( +
No folders found here.
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const entry = entries[virtualItem.index]; + const normalized = normalizePath(entry.path); + return ( +
+ togglePath(entry.path)} + onNavigate={() => setCurrentPath(entry.path)} + /> +
+ ); + })} +
+ )} +
+
+ + +
+ +
+
+
+ +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/MenuBar.tsx b/src/components/MenuBar.tsx index 7d29bf6..a48dbac 100644 --- a/src/components/MenuBar.tsx +++ b/src/components/MenuBar.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; import { MediaFilter, ZoomPreset, useGalleryStore } from "../store"; type MenuKey = "library" | "view" | "filter"; @@ -72,7 +71,7 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [ export function MenuBar() { const [openMenu, setOpenMenu] = useState(null); const rootRef = useRef(null); - const addFolder = useGalleryStore((state) => state.addFolder); + const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); const reindexFolder = useGalleryStore((state) => state.reindexFolder); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const zoomPreset = useGalleryStore((state) => state.zoomPreset); @@ -93,11 +92,8 @@ export function MenuBar() { return () => window.removeEventListener("pointerdown", handlePointerDown); }, []); - const handleAddFolder = async () => { - const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); - if (selected && typeof selected === "string") { - await addFolder(selected); - } + const handleAddFolder = () => { + setFolderPickerOpen(true); setOpenMenu(null); }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cc51fbd..a3da94e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -342,7 +342,7 @@ function FolderItem({ export function Sidebar() { const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); - const addFolder = useGalleryStore((state) => state.addFolder); + const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); const indexingProgress = useGalleryStore((state) => state.indexingProgress); const selectFolder = useGalleryStore((state) => state.selectFolder); const activeView = useGalleryStore((state) => state.activeView); @@ -459,11 +459,8 @@ export function Sidebar() { }, 400); }; - const handleAddFolder = async () => { - const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); - if (selected && typeof selected === "string") { - await addFolder(selected); - } + const handleAddFolder = () => { + setFolderPickerOpen(true); }; return ( diff --git a/src/components/onboarding/StepAddLibrary.tsx b/src/components/onboarding/StepAddLibrary.tsx index 6e0cec1..0b3411e 100644 --- a/src/components/onboarding/StepAddLibrary.tsx +++ b/src/components/onboarding/StepAddLibrary.tsx @@ -1,26 +1,12 @@ -import { useState } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore } from "../../store"; import { FakeTile } from "./fakes"; export function StepAddLibrary() { const folders = useGalleryStore((s) => s.folders); - const addFolder = useGalleryStore((s) => s.addFolder); - const [adding, setAdding] = useState(false); - const [addError, setAddError] = useState(null); + const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen); - const handlePick = async () => { - setAddError(null); - const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); - if (typeof selected !== "string") return; - setAdding(true); - try { - await addFolder(selected); - } catch (error) { - setAddError(error instanceof Error ? error.message : String(error)); - } finally { - setAdding(false); - } + const handlePick = () => { + setFolderPickerOpen(true); }; return ( @@ -49,14 +35,12 @@ export function StepAddLibrary() {

)} - {addError ?

{addError}

: null}
diff --git a/src/store.ts b/src/store.ts index 880bb30..ba4f35d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -25,6 +25,23 @@ export interface Folder { sort_order: number; } +export interface DirEntry { + name: string; + path: string; + has_children: boolean; +} + +export interface DirListing { + current: string | null; + parent: string | null; + entries: DirEntry[]; +} + +export type FolderAddResult = + | { status: "added"; data: Folder } + | { status: "skipped"; data: string } + | { status: "error"; data: string }; + export type MediaKind = "image" | "video"; export type MediaFilter = "all" | MediaKind; export type ZoomPreset = "compact" | "comfortable" | "detail"; @@ -343,6 +360,7 @@ interface GalleryState { captionDetail: CaptionDetail; aiCaptionsEnabled: boolean; settingsOpen: boolean; + folderPickerOpen: boolean; taggingQueueScope: TaggingQueueScope; taggingQueueFolderIds: number[]; mutedFolderIds: number[]; @@ -390,6 +408,8 @@ interface GalleryState { loadFolders: () => Promise; loadBackgroundJobProgress: () => Promise; addFolder: (path: string) => Promise; + addFolders: (paths: string[]) => Promise; + listDirectories: (path: string | null) => Promise; removeFolder: (folderId: number) => Promise; reindexFolder: (folderId: number) => Promise; renameFolder: (folderId: number, newName: string) => Promise; @@ -439,6 +459,7 @@ interface GalleryState { setCaptionDetail: (detail: CaptionDetail) => Promise; setAiCaptionsEnabled: (enabled: boolean) => void; setSettingsOpen: (open: boolean) => void; + setFolderPickerOpen: (open: boolean) => void; loadTaggingQueueScope: () => Promise; setTaggingQueueScope: (scope: TaggingQueueScope) => void; loadTaggingQueueFolderIds: () => Promise; @@ -773,6 +794,7 @@ export const useGalleryStore = create((set, get) => ({ captionDetail: "paragraph", aiCaptionsEnabled: initialAiCaptionsEnabled(), settingsOpen: false, + folderPickerOpen: false, taggingQueueScope: "all", taggingQueueFolderIds: [], mutedFolderIds: [], @@ -848,6 +870,16 @@ export const useGalleryStore = create((set, get) => ({ await loadBackgroundJobProgress(); }, + addFolders: async (paths) => { + const { loadFolders, loadBackgroundJobProgress } = get(); + const results = await invoke("add_folders", { paths }); + await loadFolders(); + await loadBackgroundJobProgress(); + return results; + }, + + listDirectories: (path) => invoke("list_directories", { path }), + removeFolder: async (folderId) => { const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get(); // Optimistically drop it from the sidebar for instant feedback (the backend @@ -1580,6 +1612,7 @@ export const useGalleryStore = create((set, get) => ({ }, setSettingsOpen: (settingsOpen) => set({ settingsOpen }), + setFolderPickerOpen: (folderPickerOpen) => set({ folderPickerOpen }), loadTaggingQueueScope: async () => { try { From 3684b98d552a05a3dd970126ff134eee4d879b61 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 19:21:00 +0100 Subject: [PATCH 5/5] fix(folder-picker): address QoL issues from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix chevron tooltip: was "Open folder" in both branches; now shows "No subfolders" when the entry has no children (consistent with the existing opacity-45 visual cue on the same chevron icon) - Fix Unix breadcrumb root label: was always "Home" even for non-home paths like /mnt/data — now labelled "/" which is always accurate - Fix partial-failure staging: on a mixed add result, successfully-added entries are now removed from the staging panel so only genuinely failed folders remain for the user to retry (index-pairing is safe because the backend returns results in input order via a preserved .map()) --- src/components/FolderPickerModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index c509993..047b127 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -36,7 +36,7 @@ function buildBreadcrumbs(path: string | null): { label: string; path: string | const parts = normalized.split(/[\\/]+/).filter(Boolean); let current = separator === "/" ? "" : ""; return [ - { label: "Home", path: null }, + { label: "/", path: null }, ...parts.map((part) => { current = `${current}/${part}`; return { label: part, path: current }; @@ -117,7 +117,7 @@ function FolderRow({ type="button" className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" onClick={onNavigate} - title={entry.has_children ? "Open folder" : "Open folder"} + title={entry.has_children ? "Open folder" : "No subfolders"} > @@ -300,6 +300,7 @@ export function FolderPickerModal() { const failed = addResults.filter((result) => result.status === "error"); setResults(addResults); if (failed.length > 0) { + setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error")); setError(failed.map((failure) => failure.data).join("; ")); return; }