diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index 467b9fd..9b1333b 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -1,398 +1,28 @@ -import { useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; import { Tooltip } from "./Tooltip"; -import { CheckIcon, ChevronRightIcon, CloseIcon } from "./icons"; - -function normalizePath(path: string): string { - return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); -} - -function cleanAddressInput(path: string): string { - const trimmed = path.trim(); - if (trimmed.length >= 2) { - const first = trimmed[0]; - const last = trimmed[trimmed.length - 1]; - if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) { - return trimmed.slice(1, -1).trim(); - } - } - return trimmed; -} - -function friendlyDirectoryError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - if (/cannot find the path|os error 3|not found|no such file/i.test(message)) { - return "Folder not found. Check the path and try again."; - } - return message; -} - -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: "/", 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 ( - - ); -} +import { CloseIcon } from "./icons"; +import { FolderRow } from "./folderPicker/FolderRow"; +import { StagedFoldersPanel } from "./folderPicker/StagedFoldersPanel"; +import { StatusLine } from "./folderPicker/StatusLine"; +import { normalizePath } from "./folderPicker/pathUtils"; +import { useFolderPicker } from "./folderPicker/useFolderPicker"; 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 [addressDraft, setAddressDraft] = useState(""); - const [addressEditing, setAddressEditing] = useState(false); - 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 addressInputRef = 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 folderPicker = useFolderPicker(); const virtualizer = useVirtualizer({ - count: listing?.entries.length ?? 0, - getScrollElement: () => scrollRef.current, + count: folderPicker.entries.length, + getScrollElement: () => folderPicker.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); - setAddressDraft(nextListing.current ?? ""); - setAddressEditing(false); - scrollRef.current?.scrollTo({ top: 0, left: 0 }); - }) - .catch((loadError) => { - if (cancelled) return; - setListing({ current: currentPath, parent: null, entries: [] }); - setError(friendlyDirectoryError(loadError)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [currentPath, folderPickerOpen, listDirectories]); - - useEffect(() => { - if (!folderPickerOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - if (addressEditing) { - setAddressDraft(listing?.current ?? ""); - setAddressEditing(false); - return; - } - setFolderPickerOpen(false); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]); - - useEffect(() => { - if (!addressEditing) return; - requestAnimationFrame(() => { - addressInputRef.current?.focus(); - addressInputRef.current?.select(); - }); - }, [addressEditing]); - - useEffect(() => { - if (folderPickerOpen) return; - setCurrentPath(null); - setAddressDraft(""); - setAddressEditing(false); - setListing(null); - setStagedPaths([]); - setError(null); - setResults(null); - setAdding(false); - }, [folderPickerOpen]); - - if (!folderPickerOpen) return null; - - const entries = listing?.entries ?? []; - const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? "")); - const normalizedAddressPath = addressPath ? normalizePath(addressPath) : ""; - const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false; - const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false; - - 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 stagePath = (path: string) => { - const cleaned = cleanAddressInput(path); - if (!cleaned) { - setError("Enter a folder path first."); - return; - } - - const normalized = normalizePath(cleaned); - if (libraryPaths.has(normalized)) { - setError("That folder is already in your library."); - return; - } - if (stagedSet.has(normalized)) { - setError("That folder is already selected."); - return; - } - - setError(null); - setResults(null); - setStagedPaths((current) => [...current, cleaned]); - }; - - const navigateToAddress = () => { - const cleaned = cleanAddressInput(addressDraft); - setResults(null); - setError(null); - setCurrentPath(cleaned || null); - }; - - const beginAddressEdit = () => { - setAddressDraft(listing?.current ?? ""); - setAddressEditing(true); - }; - - 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) { - setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error")); - setError(failed.map((failure) => failure.data).join("; ")); - return; - } - setFolderPickerOpen(false); - } catch (addError) { - setError(addError instanceof Error ? addError.message : String(addError)); - } finally { - setAdding(false); - } - }; + if (!folderPicker.folderPickerOpen) return null; return (
setFolderPickerOpen(false)} + onClick={() => folderPicker.setFolderPickerOpen(false)} >
setFolderPickerOpen(false)} + onClick={() => folderPicker.setFolderPickerOpen(false)} > @@ -422,36 +52,33 @@ export function FolderPickerModal() { - {addressEditing ? ( + {folderPicker.addressEditing ? (
{ event.preventDefault(); - navigateToAddress(); + folderPicker.navigateToAddress(); }} > { - setAddressDraft(event.target.value); - setResults(null); - }} + value={folderPicker.addressDraft} + onChange={(event) => folderPicker.updateAddressDraft(event.target.value)} placeholder="Paste or type a folder path" spellCheck={false} /> @@ -461,7 +88,7 @@ export function FolderPickerModal() { 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" >
@@ -490,23 +117,27 @@ export function FolderPickerModal() {
- {error ? ( + {folderPicker.error ? (
- {error} + {folderPicker.error}
) : null} -
- {loading ? ( +
+ {folderPicker.loading ? (
Loading folders...
- ) : entries.length === 0 ? ( + ) : folderPicker.entries.length === 0 ? (
No folders found here.
) : (
{virtualizer.getVirtualItems().map((virtualItem) => { - const entry = entries[virtualItem.index]; + const entry = folderPicker.entries[virtualItem.index]; const normalized = normalizePath(entry.path); return (
togglePath(entry.path)} - onNavigate={() => setCurrentPath(entry.path)} + selected={folderPicker.stagedSet.has(normalized)} + alreadyAdded={folderPicker.libraryPaths.has(normalized)} + onToggle={() => folderPicker.togglePath(entry.path)} + onNavigate={() => folderPicker.setCurrentPath(entry.path)} />
); @@ -541,33 +172,33 @@ export function FolderPickerModal() {