import React, { useState, useEffect, useRef, useCallback, useId } from "react"; import { api } from "../services/api"; interface DirectoryEntry { name: string; is_dir: boolean; git_head_subject?: string; } interface CachedDirectory { path: string; parent: string; entries: DirectoryEntry[]; git_head_subject?: string; } interface DirectoryPickerModalProps { isOpen: boolean; onClose: () => void; onSelect: (path: string) => void; initialPath?: string; } function DirectoryPickerModal({ isOpen, onClose, onSelect, initialPath, }: DirectoryPickerModalProps) { const [inputPath, setInputPath] = useState(() => { if (!initialPath) return ""; return initialPath.endsWith("/") ? initialPath : initialPath + "/"; }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); // State for create directory mode const [isCreating, setIsCreating] = useState(false); const [newDirName, setNewDirName] = useState(""); const [createError, setCreateError] = useState(null); const [createLoading, setCreateLoading] = useState(false); const newDirInputRef = useRef(null); const createInputId = useId(); // Cache for directory listings const cacheRef = useRef>(new Map()); // Current directory being displayed (the parent directory of what's being typed) const [displayDir, setDisplayDir] = useState(null); // Filter prefix (the part after the last slash that we're filtering by) const [filterPrefix, setFilterPrefix] = useState(""); // Parse input path into directory and filter prefix const parseInputPath = useCallback((path: string): { dirPath: string; prefix: string } => { if (!path) { return { dirPath: "", prefix: "" }; } // If path ends with /, we're looking at contents of that directory if (path.endsWith("/")) { return { dirPath: path.slice(0, -1) || "/", prefix: "" }; } // Otherwise, split into directory and prefix const lastSlash = path.lastIndexOf("/"); if (lastSlash === -1) { // No slash, treat as prefix in current directory return { dirPath: "", prefix: path }; } if (lastSlash === 0) { // Root directory with prefix return { dirPath: "/", prefix: path.slice(1) }; } return { dirPath: path.slice(0, lastSlash), prefix: path.slice(lastSlash + 1), }; }, []); // Load directory from cache or API const loadDirectory = useCallback(async (path: string): Promise => { const normalizedPath = path || "/"; // Check cache first const cached = cacheRef.current.get(normalizedPath); if (cached) { return cached; } // Load from API setLoading(true); setError(null); try { const result = await api.listDirectory(path || undefined); if (result.error) { setError(result.error); return null; } const dirData: CachedDirectory = { path: result.path, parent: result.parent, entries: result.entries || [], git_head_subject: result.git_head_subject, }; // Cache it cacheRef.current.set(result.path, dirData); return dirData; } catch (err) { setError(err instanceof Error ? err.message : "Failed to load directory"); return null; } finally { setLoading(false); } }, []); // Track the current expected path to avoid race conditions const expectedPathRef = useRef(""); // Update display when input changes useEffect(() => { if (!isOpen) return; const { dirPath, prefix } = parseInputPath(inputPath); setFilterPrefix(prefix); // Track which path we expect to display const normalizedDirPath = dirPath || "/"; expectedPathRef.current = normalizedDirPath; // Load the directory loadDirectory(dirPath).then((dir) => { // Only update if this is still the path we want if (dir && expectedPathRef.current === normalizedDirPath) { setDisplayDir(dir); setError(null); } }); }, [isOpen, inputPath, parseInputPath, loadDirectory]); // Initialize when modal opens useEffect(() => { if (isOpen) { if (!initialPath) { setInputPath(""); } else { setInputPath(initialPath.endsWith("/") ? initialPath : initialPath + "/"); } // Clear cache on open to get fresh data cacheRef.current.clear(); } }, [isOpen, initialPath]); // Focus input when modal opens (but not on mobile to avoid keyboard popup) useEffect(() => { if (isOpen && inputRef.current) { // Check if mobile device (touch-based) const isMobile = window.matchMedia("(max-width: 768px)").matches || "ontouchstart" in window; if (!isMobile) { inputRef.current.focus(); // Move cursor to end const len = inputRef.current.value.length; inputRef.current.setSelectionRange(len, len); } } }, [isOpen]); // Filter entries based on prefix (case-insensitive) const filteredEntries = displayDir?.entries.filter((entry) => { if (!filterPrefix) return true; return entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase()); }) || []; const handleEntryClick = (entry: DirectoryEntry) => { if (entry.is_dir) { const basePath = displayDir?.path || ""; const newPath = basePath === "/" ? `/${entry.name}/` : `${basePath}/${entry.name}/`; setInputPath(newPath); } }; const handleParentClick = () => { if (displayDir?.parent) { const newPath = displayDir.parent === "/" ? "/" : `${displayDir.parent}/`; setInputPath(newPath); } }; const handleInputKeyDown = (e: React.KeyboardEvent) => { // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji) if (e.nativeEvent.isComposing) { return; } if (e.key === "Enter") { e.preventDefault(); handleSelect(); } }; const handleSelect = () => { // Use the current directory path for selection const { dirPath } = parseInputPath(inputPath); const selectedPath = inputPath.endsWith("/") ? (dirPath === "/" ? "/" : dirPath) : dirPath; onSelect(selectedPath || displayDir?.path || ""); onClose(); }; // Focus the new directory input when entering create mode useEffect(() => { if (isCreating && newDirInputRef.current) { newDirInputRef.current.focus(); } }, [isCreating]); const handleStartCreate = () => { setIsCreating(true); setNewDirName(""); setCreateError(null); }; const handleCancelCreate = () => { setIsCreating(false); setNewDirName(""); setCreateError(null); }; const handleCreateDirectory = async () => { if (!newDirName.trim()) { setCreateError("Directory name is required"); return; } // Validate directory name (no path separators or special chars) if (newDirName.includes("/") || newDirName.includes("\\")) { setCreateError("Directory name cannot contain slashes"); return; } const basePath = displayDir?.path || "/"; const newPath = basePath === "/" ? `/${newDirName}` : `${basePath}/${newDirName}`; setCreateLoading(true); setCreateError(null); try { const result = await api.createDirectory(newPath); if (result.error) { setCreateError(result.error); return; } // Clear the cache for the current directory so it reloads with the new dir cacheRef.current.delete(basePath); // Exit create mode and navigate to the new directory setIsCreating(false); setNewDirName(""); setInputPath(newPath + "/"); } catch (err) { setCreateError(err instanceof Error ? err.message : "Failed to create directory"); } finally { setCreateLoading(false); } }; const handleCreateKeyDown = (e: React.KeyboardEvent) => { if (e.nativeEvent.isComposing) return; if (e.key === "Enter") { e.preventDefault(); handleCreateDirectory(); } else if (e.key === "Escape") { e.preventDefault(); handleCancelCreate(); } }; const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; if (!isOpen) return null; // Determine if we should show the parent entry const showParent = displayDir?.parent && displayDir.parent !== ""; return (
{/* Header */}

Select Directory

{/* Content */}
{/* Path input */}
setInputPath(e.target.value)} onKeyDown={handleInputKeyDown} className="directory-picker-input" placeholder="/path/to/directory" />
{/* Current directory indicator */} {displayDir && (
{displayDir.path} {filterPrefix && /{filterPrefix}*} {displayDir.git_head_subject && ( {displayDir.git_head_subject} )}
)} {/* Error message */} {error &&
{error}
} {/* Loading state */} {loading && (
Loading...
)} {/* Directory listing */} {!loading && !error && (
{/* Parent directory entry */} {showParent && ( )} {/* Directory entries */} {filteredEntries.map((entry) => ( ))} {/* Create new directory inline form */} {isCreating && (
setNewDirName(e.target.value)} onKeyDown={handleCreateKeyDown} placeholder="New folder name" className="directory-picker-create-input" disabled={createLoading} />
)} {/* Create error message */} {createError &&
{createError}
} {/* Empty state */} {filteredEntries.length === 0 && !showParent && !isCreating && (
{filterPrefix ? "No matching directories" : "No subdirectories"}
)}
)}
{/* Footer */}
{/* New Folder button */} {!isCreating && !loading && !error && ( )}
); } export default DirectoryPickerModal;