"use client"; import { Fragment, useCallback, useEffect, useRef, useState, } from "react"; // ── Types ── type BrowseEntry = { name: string; path: string; type: "folder" | "file" | "document" | "database"; children?: BrowseEntry[]; }; export type SelectedFile = { name: string; path: string; }; type FilePickerModalProps = { open: boolean; onClose: () => void; onSelect: (files: SelectedFile[]) => void; }; // ── Helpers ── function getCategoryFromName( name: string, ): "image" | "video" | "audio" | "pdf" | "code" | "document" | "folder" | "other" { const ext = name.split(".").pop()?.toLowerCase() ?? ""; if ( ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext) ) {return "image";} if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";} if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";} if (ext === "pdf") {return "pdf";} if ( [ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java", "cpp", "c", "h", "css", "html", "json", "yaml", "yml", "toml", "md", "sh", "bash", "sql", "swift", "kt", ].includes(ext) ) {return "code";} if ( ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext) ) {return "document";} return "other"; } function buildBreadcrumbs( dir: string, ): { label: string; path: string }[] { const segments: { label: string; path: string }[] = []; const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/); const homeDir = homeMatch?.[1]; if (homeDir) { segments.push({ label: "~", path: homeDir }); const rest = dir.slice(homeDir.length); const parts = rest.split("/").filter(Boolean); let currentPath = homeDir; for (const part of parts) { currentPath += "/" + part; segments.push({ label: part, path: currentPath }); } } else if (dir === "/") { segments.push({ label: "/", path: "/" }); } else { segments.push({ label: "/", path: "/" }); const parts = dir.split("/").filter(Boolean); let currentPath = ""; for (const part of parts) { currentPath += "/" + part; segments.push({ label: part, path: currentPath }); } } return segments; } const pickerColors: Record = { folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" }, image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" }, video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" }, audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" }, pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" }, code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" }, document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" }, other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" }, }; // ── Icons ── function PickerIcon({ category, size = 16, }: { category: string; size?: number; }) { const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, }; switch (category) { case "folder": return ( ); case "image": return ( ); case "video": return ( ); case "audio": return ( ); case "pdf": return ( ); case "code": return ( ); case "document": return ( ); default: return ( ); } } // ── Main component ── export function FilePickerModal({ open, onClose, onSelect, }: FilePickerModalProps) { const [currentDir, setCurrentDir] = useState(null); const [displayDir, setDisplayDir] = useState(""); const [entries, setEntries] = useState([]); const [parentDir, setParentDir] = useState(null); const [selected, setSelected] = useState< Map >(new Map()); const [loading, setLoading] = useState(false); const [search, setSearch] = useState(""); const [creatingFolder, setCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [error, setError] = useState(null); // Animation const [visible, setVisible] = useState(false); useEffect(() => { if (open) { requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)), ); } else { setVisible(false); } }, [open]); // Reset transient state on close useEffect(() => { if (!open) { setSearch(""); setCreatingFolder(false); setNewFolderName(""); setError(null); } }, [open]); // Search input ref for autofocus const searchRef = useRef(null); const newFolderRef = useRef(null); // Fetch directory const fetchDir = useCallback(async (dir: string | null) => { setLoading(true); setError(null); try { const url = dir ? `/api/workspace/browse?dir=${encodeURIComponent(dir)}` : "/api/workspace/browse"; const res = await fetch(url); if (!res.ok) {throw new Error("Failed to list directory");} const data = await res.json(); setEntries(data.entries || []); setDisplayDir(data.currentDir || ""); setParentDir(data.parentDir ?? null); } catch { setError("Could not load this directory"); setEntries([]); } finally { setLoading(false); } }, []); // Fetch on open and navigation useEffect(() => { if (open) { void fetchDir(currentDir); } }, [open, currentDir, fetchDir]); // Escape key useEffect(() => { if (!open) {return;} const handler = (e: KeyboardEvent) => { if (e.key === "Escape") {onClose();} }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [open, onClose]); // Handlers const toggleSelect = useCallback( (entry: BrowseEntry) => { setSelected((prev) => { const next = new Map(prev); if (next.has(entry.path)) { next.delete(entry.path); } else { next.set(entry.path, { name: entry.name, path: entry.path, }); } return next; }); }, [], ); const navigateInto = useCallback((path: string) => { setCurrentDir(path); setSearch(""); setCreatingFolder(false); }, []); const handleCreateFolder = useCallback(async () => { if (!newFolderName.trim() || !displayDir) {return;} const folderPath = `${displayDir}/${newFolderName.trim()}`; try { await fetch("/api/workspace/mkdir", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: folderPath }), }); setCreatingFolder(false); setNewFolderName(""); void fetchDir(currentDir); } catch { setError("Failed to create folder"); } }, [newFolderName, displayDir, currentDir, fetchDir]); const handleConfirm = useCallback(() => { onSelect(Array.from(selected.values())); setSelected(new Map()); onClose(); }, [selected, onSelect, onClose]); // Filter & sort entries (folders first, then alphabetically) const sorted = entries .filter( (e) => !search || e.name .toLowerCase() .includes(search.toLowerCase()), ) .toSorted((a, b) => { if (a.type === "folder" && b.type !== "folder") {return -1;} if (a.type !== "folder" && b.type === "folder") {return 1;} return a.name.localeCompare(b.name); }); const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : []; if (!open) {return null;} return (
{/* Backdrop */}
{/* Modal */}
{/* Header */}

Select Files

Browse and attach files

{/* Breadcrumb path */} {displayDir && (
{breadcrumbs.map( (seg, i) => ( {i > 0 && ( / )} ), )}
)} {/* Search bar + New Folder */}
setSearch( e.target.value, ) } placeholder="Filter files..." className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]" style={{ color: "var(--color-text)", }} />
{/* File list */}
{loading ? (
) : error ? (
{error}
) : ( <> {/* Parent directory row */} {parentDir && ( )} {/* New folder input */} {creatingFolder && (
setNewFolderName( e .target .value, ) } onKeyDown={( e, ) => { if ( e.key === "Enter" ) { void handleCreateFolder(); } if ( e.key === "Escape" ) { setCreatingFolder( false, ); setNewFolderName( "", ); } }} placeholder="Folder name..." className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1" style={{ color: "var(--color-text)", background: "var(--color-surface)", border: "1px solid var(--color-accent)", }} />
)} {/* Entries */} {sorted.length === 0 && !parentDir && (
This folder is empty
)} {sorted.map( (entry) => { const isFolder = entry.type === "folder"; const category = isFolder ? "folder" : getCategoryFromName( entry.name, ); const colors = pickerColors[ category ] ?? pickerColors.other; const isSelected = selected.has( entry.path, ); return (
{ if ( isFolder ) { navigateInto( entry.path, ); } else { toggleSelect( entry, ); } }} > {/* Checkbox */} {/* Icon */}
{/* Name */} { entry.name } {/* Folder chevron */} {isFolder && ( )}
); }, )} )}
{/* Footer */}
{selected.size > 0 ? `${selected.size} ${selected.size === 1 ? "item" : "items"} selected` : "No files selected"}
); }