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 {