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.
This commit is contained in:
2026-06-21 17:38:01 +01:00
parent f66fbe7931
commit 74a4134f2f
8 changed files with 720 additions and 42 deletions
+224 -8
View File
@@ -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<i64>,
}
#[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<Folder, String> {
) -> Result<AddOutcome, String> {
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<Folder, String> {
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<String>,
) -> Result<Vec<FolderAddResult>, 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<Vec<Folder>, String>
db::get_folders(&conn).map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct DirListing {
pub current: Option<String>,
pub parent: Option<String>,
pub entries: Vec<DirEntry>,
}
#[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<PathBuf> {
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<DirEntry> {
let mut seen = HashSet::new();
let mut entries: Vec<DirEntry> = 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<Vec<DirEntry>, 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<String>) -> Result<DirListing, String> {
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(&current_path)?;
Ok(DirListing {
current: Some(path_to_string(&current_path)),
parent,
entries,
})
}
#[derive(Deserialize)]
pub struct ReorderFoldersParams {
pub folder_ids: Vec<i64>,
+2
View File
@@ -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,
+2
View File
@@ -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() {
<Lightbox />
<SettingsModal />
<FolderPickerModal />
<UpdateToast />
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
+448
View File
@@ -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 (
<p className="text-xs text-gray-500 light-theme:text-gray-600">
Added {added}, skipped {skipped}, failed {failed}.
</p>
);
}
function FolderRow({
entry,
selected,
alreadyAdded,
onToggle,
onNavigate,
}: {
entry: DirEntry;
selected: boolean;
alreadyAdded: boolean;
onToggle: () => void;
onNavigate: () => void;
}) {
return (
<div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
alreadyAdded
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
: selected
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<button
type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
selected
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
onClick={onToggle}
disabled={alreadyAdded}
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={onNavigate}
title={entry.path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<span className="truncate text-sm">{entry.name}</span>
</button>
{alreadyAdded ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
Added
</span>
) : null}
<button
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"}
>
<svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
function StagedFoldersPanel({
stagedPaths,
onRemove,
onClear,
}: {
stagedPaths: string[];
onRemove: (path: string) => void;
onClear: () => void;
}) {
return (
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
Folders to add ({stagedPaths.length})
</p>
{stagedPaths.length > 0 ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={onClear}
>
Clear all
</button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{stagedPaths.length === 0 ? (
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
<p className="text-sm text-gray-500">No folders selected.</p>
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
Choose folders on the left and they will collect here.
</p>
</div>
) : (
<div className="space-y-2">
{stagedPaths.map((path) => (
<div
key={path}
className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
title={path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
</div>
<button
type="button"
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`}
title="Remove from folders to add"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</aside>
);
}
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<DirListing | null>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<FolderAddResult[] | null>(null);
const scrollRef = useRef<HTMLDivElement>(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 (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={() => setFolderPickerOpen(false)}
>
<div
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"
onClick={(event) => event.stopPropagation()}
>
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-base font-semibold text-white">Add media folders</p>
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p>
</div>
<button
type="button"
className="rounded-md p-1.5 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={() => setFolderPickerOpen(false)}
title="Close folder picker"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<main className="flex min-h-0 flex-1 flex-col px-5 py-4">
<div className="mb-4 flex items-center gap-2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => setCurrentPath(listing?.parent ?? null)}
disabled={!listing?.current}
>
Up
</button>
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900">
{breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
<button
type="button"
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setCurrentPath(crumb.path)}
title={crumb.path ?? "Roots"}
>
{crumb.label}
</button>
</span>
))}
</nav>
</div>
{error ? (
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800">
{error}
</div>
) : null}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
) : entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
) : (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const entry = entries[virtualItem.index];
const normalized = normalizePath(entry.path);
return (
<div
key={virtualItem.key}
className="absolute left-0 top-0 w-full px-0.5"
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<FolderRow
entry={entry}
selected={stagedSet.has(normalized)}
alreadyAdded={libraryPaths.has(normalized)}
onToggle={() => togglePath(entry.path)}
onNavigate={() => setCurrentPath(entry.path)}
/>
</div>
);
})}
</div>
)}
</div>
</main>
<StagedFoldersPanel
stagedPaths={stagedPaths}
onRemove={removeStagedPath}
onClear={clearStagedPaths}
/>
</div>
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1">
<StatusLine results={results} />
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => setFolderPickerOpen(false)}
>
Cancel
</button>
<button
type="button"
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => void confirmAdd()}
disabled={stagedPaths.length === 0 || adding}
>
{adding ? "Adding..." : `Add ${stagedPaths.length}`}
</button>
</div>
</div>
</footer>
</div>
</div>
);
}
+3 -7
View File
@@ -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<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(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);
};
+3 -6
View File
@@ -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 (
+5 -21
View File
@@ -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<string | null>(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() {
</p>
</>
)}
{addError ? <p className="mt-1.5 text-xs text-amber-300/90 light-theme:text-amber-700">{addError}</p> : null}
</div>
<button
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
onClick={() => void handlePick()}
disabled={adding}
onClick={handlePick}
>
{adding ? "Adding..." : folders.length > 0 ? "Add another folder" : "Choose a folder"}
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
</button>
</div>
+33
View File
@@ -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<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
addFolders: (paths: string[]) => Promise<FolderAddResult[]>;
listDirectories: (path: string | null) => Promise<DirListing>;
removeFolder: (folderId: number) => Promise<void>;
reindexFolder: (folderId: number) => Promise<void>;
renameFolder: (folderId: number, newName: string) => Promise<void>;
@@ -439,6 +459,7 @@ interface GalleryState {
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
setAiCaptionsEnabled: (enabled: boolean) => void;
setSettingsOpen: (open: boolean) => void;
setFolderPickerOpen: (open: boolean) => void;
loadTaggingQueueScope: () => Promise<void>;
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
loadTaggingQueueFolderIds: () => Promise<void>;
@@ -773,6 +794,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
captionDetail: "paragraph",
aiCaptionsEnabled: initialAiCaptionsEnabled(),
settingsOpen: false,
folderPickerOpen: false,
taggingQueueScope: "all",
taggingQueueFolderIds: [],
mutedFolderIds: [],
@@ -848,6 +870,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await loadBackgroundJobProgress();
},
addFolders: async (paths) => {
const { loadFolders, loadBackgroundJobProgress } = get();
const results = await invoke<FolderAddResult[]>("add_folders", { paths });
await loadFolders();
await loadBackgroundJobProgress();
return results;
},
listDirectories: (path) => invoke<DirListing>("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<GalleryState>((set, get) => ({
},
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
setFolderPickerOpen: (folderPickerOpen) => set({ folderPickerOpen }),
loadTaggingQueueScope: async () => {
try {