From 862a33982d8e6e2f783ad5ec237f0aa2456accc3 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 00:47:45 +0100 Subject: [PATCH] feat: enhance FileBrowserDialog and PathInput with search functionality - Added Kbd and KbdGroup components for keyboard shortcuts in FileBrowserDialog. - Implemented search functionality in PathInput, allowing users to search files and directories. - Updated PathInput to handle file system entries and selection from search results. - Improved UI/UX with better focus management and search input handling. --- .../dialogs/file-browser-dialog.tsx | 23 +- apps/ui/src/components/ui/path-input.tsx | 311 +++++++++++++----- 2 files changed, 249 insertions(+), 85 deletions(-) diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index 9dae11bb..ff41c22b 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { PathInput } from '@/components/ui/path-input'; +import { Kbd, KbdGroup } from '@/components/ui/kbd'; import { getJSON, setJSON } from '@/lib/storage'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; @@ -232,7 +233,7 @@ export function FileBrowserDialog({ return ( - + @@ -252,6 +253,12 @@ export function FileBrowserDialog({ error={!!error} onNavigate={handleNavigate} onHome={handleGoHome} + entries={directories.map((dir) => ({ ...dir, isDirectory: true }))} + onSelectEntry={(entry) => { + if (entry.isDirectory) { + handleSelectDirectory(entry); + } + }} /> {/* Recent folders */} @@ -366,12 +373,14 @@ export function FileBrowserDialog({ > Select Current Folder - - {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') - ? '⌘' - : 'Ctrl'} - +↵ - + + + {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') + ? '⌘' + : 'Ctrl'} + + + diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index 7840c16d..87829ec0 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -1,6 +1,6 @@ import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; import { useState, useRef, useCallback, useMemo } from 'react'; -import { Home, ArrowLeft, Pencil, ArrowRight } from 'lucide-react'; +import { Home, ArrowLeft, Pencil, ArrowRight, Search, Folder, File, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -11,6 +11,14 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from '@/components/ui/command'; import { cn } from '@/lib/utils'; interface BreadcrumbSegment { @@ -52,6 +60,12 @@ function parseBreadcrumbs(path: string): BreadcrumbSegment[] { }); } +interface FileSystemEntry { + name: string; + path: string; + isDirectory: boolean; +} + interface PathInputProps { /** Current resolved path */ currentPath: string; @@ -63,12 +77,18 @@ interface PathInputProps { error?: boolean; /** Placeholder text for the input field */ placeholder?: string; + /** Placeholder text for the search input field */ + searchPlaceholder?: 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; + /** List of files and directories in current path for search functionality */ + entries?: FileSystemEntry[]; + /** Called when user selects an entry from search results */ + onSelectEntry?: (entry: FileSystemEntry) => void; } function PathInput({ @@ -77,11 +97,15 @@ function PathInput({ loading = false, error, placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)', + searchPlaceholder = 'Search...', onNavigate, onHome, className, + entries = [], + onSelectEntry, }: PathInputProps) { const [isEditing, setIsEditing] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); const [pathInput, setPathInput] = useState(currentPath); const inputRef = useRef(null); const containerRef = useRef(null); @@ -103,6 +127,21 @@ function PathInput({ } }, [error, isEditing]); + // Focus search input when search opens + useEffect(() => { + if (isSearchOpen) { + // Small delay to ensure the CommandInput is rendered + setTimeout(() => { + const searchInput = containerRef.current?.querySelector( + '[data-slot="command-input"]' + ) as HTMLInputElement; + if (searchInput) { + searchInput.focus(); + } + }, 0); + } + }, [isSearchOpen]); + const handleGoToParent = useCallback(() => { if (parentPath) { onNavigate(parentPath); @@ -163,6 +202,7 @@ function PathInput({ // Don't trigger if clicking on a button or already editing if ( isEditing || + isSearchOpen || (e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('a') ) { @@ -170,9 +210,59 @@ function PathInput({ } setIsEditing(true); }, - [isEditing] + [isEditing, isSearchOpen] ); + const handleSelectEntry = useCallback( + (entry: FileSystemEntry) => { + if (onSelectEntry) { + onSelectEntry(entry); + } + setIsSearchOpen(false); + }, + [onSelectEntry] + ); + + // Global keyboard shortcut to activate search (/) + useEffect(() => { + const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { + // Activate search with '/' key (unless in an input field) + if ( + e.key === '/' && + !isEditing && + !isSearchOpen && + !(e.target as HTMLElement).matches('input, textarea') + ) { + e.preventDefault(); + setIsSearchOpen(true); + } + // Close search with Escape key + if (e.key === 'Escape' && isSearchOpen) { + e.preventDefault(); + e.stopPropagation(); // Prevent parent modal from closing + setIsSearchOpen(false); + } + }; + + window.addEventListener('keydown', handleGlobalKeyDown, true); // Use capture phase + return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [isEditing, isSearchOpen]); + + // Close search when clicking outside + useEffect(() => { + if (!isSearchOpen) return; + + const handleClickOutside = (e: globalThis.MouseEvent) => { + const target = e.target as HTMLElement; + if (containerRef.current && !containerRef.current.contains(target)) { + setIsSearchOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isSearchOpen]); + const breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]); const showBreadcrumbs = currentPath && !isEditing && !loading && !error; @@ -210,87 +300,152 @@ function PathInput({ {/* Path display / input */} -
- {showBreadcrumbs ? ( - <> - - - {breadcrumbs.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]" +
+ {/* Search Popover - positioned to overlap the input */} + {isSearchOpen && ( +
+
+
+ +
+ +
+ + + ESC + +
+
+ + No files or directories found + + {entries.map((entry) => ( + handleSelectEntry(entry)} > - {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} - /> - - + {entry.isDirectory ? ( + + ) : ( + + )} + {entry.name} + + ))} + + +
+
+
+
)} + +
+ {showBreadcrumbs ? ( + <> + + + {breadcrumbs.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 }; +export type { PathInputProps, BreadcrumbSegment, FileSystemEntry };