diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index dc9c1c2e..ed3f8aaf 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -1,15 +1,5 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { - FolderOpen, - Folder, - ChevronRight, - Home, - ArrowLeft, - HardDrive, - CornerDownLeft, - Clock, - X, -} from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { FolderOpen, Folder, ChevronRight, HardDrive, Clock, X } from 'lucide-react'; import { Dialog, DialogContent, @@ -19,7 +9,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { PathInput } from '@/components/ui/path-input'; import { getJSON, setJSON } from '@/lib/storage'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; @@ -78,7 +68,6 @@ export function FileBrowserDialog({ initialPath, }: FileBrowserDialogProps) { const [currentPath, setCurrentPath] = useState(''); - const [pathInput, setPathInput] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); const [drives, setDrives] = useState([]); @@ -86,7 +75,6 @@ export function FileBrowserDialog({ const [error, setError] = useState(''); const [warning, setWarning] = useState(''); const [recentFolders, setRecentFolders] = useState([]); - const pathInputRef = useRef(null); // Load recent folders when dialog opens useEffect(() => { @@ -120,7 +108,6 @@ export function FileBrowserDialog({ if (result.success) { setCurrentPath(result.currentPath); - setPathInput(result.currentPath); setParentPath(result.parentPath); setDirectories(result.directories); setDrives(result.drives || []); @@ -142,11 +129,10 @@ export function FileBrowserDialog({ [browseDirectory] ); - // Reset current path when dialog closes + // Reset state when dialog closes useEffect(() => { if (!open) { setCurrentPath(''); - setPathInput(''); setParentPath(null); setDirectories([]); setError(''); @@ -172,9 +158,6 @@ export function FileBrowserDialog({ const pathToUse = initialPath || defaultDir; if (pathToUse) { - // Pre-fill the path input immediately - setPathInput(pathToUse); - // Then browse to that directory browseDirectory(pathToUse); } else { // No default directory, browse home directory @@ -183,7 +166,6 @@ export function FileBrowserDialog({ } catch { // If config fetch fails, try initialPath or fall back to home directory if (initialPath) { - setPathInput(initialPath); browseDirectory(initialPath); } else { browseDirectory(); @@ -199,34 +181,21 @@ export function FileBrowserDialog({ browseDirectory(dir.path); }; - const handleGoToParent = () => { - if (parentPath) { - browseDirectory(parentPath); - } - }; - - const handleGoHome = () => { + const handleGoHome = useCallback(() => { browseDirectory(); - }; + }, [browseDirectory]); + + const handleNavigate = useCallback( + (path: string) => { + browseDirectory(path); + }, + [browseDirectory] + ); const handleSelectDrive = (drivePath: string) => { browseDirectory(drivePath); }; - const handleGoToPath = () => { - const trimmedPath = pathInput.trim(); - if (trimmedPath) { - browseDirectory(trimmedPath); - } - }; - - const handlePathInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleGoToPath(); - } - }; - const handleSelect = useCallback(() => { if (currentPath) { addRecentFolder(currentPath); @@ -275,31 +244,15 @@ export function FileBrowserDialog({
- {/* Direct path input */} -
- setPathInput(e.target.value)} - onKeyDown={handlePathInputKeyDown} - className="flex-1 font-mono text-xs h-8" - data-testid="path-input" - disabled={loading} - /> - -
+ {/* Path navigation */} + {/* Recent folders */} {recentFolders.length > 0 && ( @@ -352,35 +305,8 @@ export function FileBrowserDialog({
)} - {/* Current path breadcrumb */} -
- - {parentPath && ( - - )} -
- {currentPath || 'Loading...'} -
-
- {/* Directory list */} -
+
{loading && (
Loading directories...
diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx new file mode 100644 index 00000000..e20bed9e --- /dev/null +++ b/apps/ui/src/components/ui/path-input.tsx @@ -0,0 +1,277 @@ +import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; +import { useState, useRef, useCallback } from 'react'; +import { Home, ArrowLeft, Pencil } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { cn } from '@/lib/utils'; + +interface BreadcrumbSegment { + name: string; + path: string; + isLast: boolean; +} + +function parseBreadcrumbs(path: string): BreadcrumbSegment[] { + if (!path) return []; + + const segments = path.split(/[/\\]/).filter(Boolean); + const isWindows = segments[0]?.includes(':'); + + return segments.map((segment, index) => { + let fullPath: string; + + if (isWindows) { + const pathParts = segments.slice(0, index + 1); + if (index === 0) { + fullPath = `${pathParts[0]}\\`; + } else { + fullPath = pathParts.join('\\'); + } + } else { + fullPath = '/' + segments.slice(0, index + 1).join('/'); + } + + return { + name: segment, + path: fullPath, + isLast: index === segments.length - 1, + }; + }); +} + +interface PathInputProps { + /** Current resolved path */ + currentPath: string; + /** Parent path for back navigation (null if at root) */ + parentPath: string | null; + /** Whether the component is in a loading state */ + loading?: boolean; + /** Whether there's an error (shows input mode and red border when true) */ + error?: boolean; + /** Placeholder text for the input field */ + placeholder?: string; + /** Called when user navigates to a path (via breadcrumb click, enter key, or navigation buttons) */ + onNavigate: (path: string) => void; + /** Called when user clicks home button (navigates to home directory) */ + onHome: () => void; + /** Additional className for the container */ + className?: string; +} + +function PathInput({ + currentPath, + parentPath, + loading = false, + error, + placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)', + onNavigate, + onHome, + className, +}: PathInputProps) { + const [isEditing, setIsEditing] = useState(false); + const [pathInput, setPathInput] = useState(currentPath); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Sync pathInput with currentPath when it changes externally + useEffect(() => { + if (!isEditing) { + setPathInput(currentPath); + } + }, [currentPath, isEditing]); + + // Focus input when error occurs or entering edit mode + useEffect(() => { + if ((error || isEditing) && inputRef.current) { + inputRef.current.focus(); + if (error) { + inputRef.current.select(); + } + } + }, [error, isEditing]); + + const handleGoToParent = useCallback(() => { + if (parentPath) { + onNavigate(parentPath); + } + }, [parentPath, onNavigate]); + + const handleBreadcrumbClick = useCallback( + (path: string) => { + onNavigate(path); + }, + [onNavigate] + ); + + const handleStartEditing = useCallback(() => { + setIsEditing(true); + }, []); + + const handleInputBlur = useCallback( + (e: FocusEvent) => { + // Check if focus is moving to another element within this component + if (containerRef.current?.contains(e.relatedTarget)) { + return; + } + if (pathInput !== currentPath) { + setPathInput(currentPath); + } + setIsEditing(false); + }, + [pathInput, currentPath] + ); + + const handleGoToPath = useCallback(() => { + const trimmedPath = pathInput.trim(); + if (trimmedPath) { + onNavigate(trimmedPath); + setIsEditing(false); + } + }, [pathInput, onNavigate]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleGoToPath(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setPathInput(currentPath); + setIsEditing(false); + inputRef.current?.blur(); + } + }, + [handleGoToPath, currentPath] + ); + + // Handle click on the path container to start editing + const handleContainerClick = useCallback( + (e: MouseEvent) => { + // Don't trigger if clicking on a button or already editing + if ( + isEditing || + (e.target as HTMLElement).closest('button') || + (e.target as HTMLElement).closest('a') + ) { + return; + } + setIsEditing(true); + }, + [isEditing] + ); + + const showBreadcrumbs = currentPath && !isEditing && !loading && !error; + + return ( +
+ {/* Navigation buttons */} +
+ + + +
+ + {/* Path display / input */} +
+ {showBreadcrumbs ? ( + <> + + + {parseBreadcrumbs(currentPath).map((crumb) => ( + + + {crumb.isLast ? ( + + {crumb.name} + + ) : ( + { + e.preventDefault(); + handleBreadcrumbClick(crumb.path); + }} + className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]" + > + {crumb.name} + + )} + + {!crumb.isLast && } + + ))} + + + + + ) : ( + setPathInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + className="flex-1 font-mono text-xs h-7 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent" + data-testid="path-input" + disabled={loading} + aria-label="Path input" + aria-invalid={error} + /> + )} +
+
+ ); +} + +export { PathInput, parseBreadcrumbs }; +export type { PathInputProps, BreadcrumbSegment };