diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index 9dae11bb..02552c28 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -10,8 +10,10 @@ 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'; +import { useOSDetection } from '@/hooks'; interface DirectoryEntry { name: string; @@ -67,6 +69,7 @@ export function FileBrowserDialog({ description = 'Navigate to your project folder or paste a path directly', initialPath, }: FileBrowserDialogProps) { + const { isMac } = useOSDetection(); const [currentPath, setCurrentPath] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); @@ -232,7 +235,7 @@ export function FileBrowserDialog({ return ( - + @@ -252,6 +255,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 +375,10 @@ export function FileBrowserDialog({ > Select Current Folder - - {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') - ? '⌘' - : 'Ctrl'} - +↵ - + + {isMac ? '⌘' : 'Ctrl'} + + diff --git a/apps/ui/src/components/ui/kbd.tsx b/apps/ui/src/components/ui/kbd.tsx new file mode 100644 index 00000000..670a3872 --- /dev/null +++ b/apps/ui/src/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index 7840c16d..c748d867 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -1,8 +1,9 @@ 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 { Kbd } from '@/components/ui/kbd'; import { Breadcrumb, BreadcrumbList, @@ -11,6 +12,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 +61,12 @@ function parseBreadcrumbs(path: string): BreadcrumbSegment[] { }); } +interface FileSystemEntry { + name: string; + path: string; + isDirectory: boolean; +} + interface PathInputProps { /** Current resolved path */ currentPath: string; @@ -63,12 +78,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 +98,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); @@ -163,6 +188,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,11 +196,79 @@ 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 or contenteditable) + if ( + e.key === '/' && + !isEditing && + !isSearchOpen && + entries.length > 0 && + !(e.target as HTMLElement).matches('input, textarea, [contenteditable="true"]') + ) { + e.preventDefault(); + setIsSearchOpen(true); + } + // Close search with Escape key + if (e.key === 'Escape' && isSearchOpen) { + e.preventDefault(); + e.stopPropagation(); // Stop propagation so parent modal doesn't close + setIsSearchOpen(false); + } + }; + + // Use capture phase to intercept ESC before parent modal handlers + // This allows us to close search first, then let ESC bubble to close modal on second press + window.addEventListener('keydown', handleGlobalKeyDown, true); + return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [isEditing, isSearchOpen, entries.length]); + + // 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 entryItems = useMemo( + () => + entries.map((entry) => ( + handleSelectEntry(entry)}> + {entry.isDirectory ? ( + + ) : ( + + )} + {entry.name} + + )), + [entries, handleSelectEntry] + ); + const showBreadcrumbs = currentPath && !isEditing && !loading && !error; return ( @@ -210,87 +304,139 @@ function PathInput({ {/* Path display / input */} -
+ {/* Search Popover - positioned to overlap the input */} + {isSearchOpen && ( +
+
+
+ +
+ +
+ + ESC +
+
+ + No files or directories found + {entryItems} + +
+
+
+
)} - > - {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} - + +
+ {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 && ( + )} - - {!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} - /> - - - )} + + ))} + + +
+ + +
+ + ) : ( + <> + 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 }; diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index b18a85e6..8f2264d6 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -3,6 +3,7 @@ export { useBoardBackgroundSettings } from './use-board-background-settings'; export { useElectronAgent } from './use-electron-agent'; export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; export { useMessageQueue } from './use-message-queue'; +export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection'; export { useResponsiveKanban } from './use-responsive-kanban'; export { useScrollTracking } from './use-scroll-tracking'; export { useSettingsMigration } from './use-settings-migration'; diff --git a/apps/ui/src/hooks/use-os-detection.ts b/apps/ui/src/hooks/use-os-detection.ts new file mode 100644 index 00000000..a7bcf68b --- /dev/null +++ b/apps/ui/src/hooks/use-os-detection.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; + +export type OperatingSystem = 'mac' | 'windows' | 'linux' | 'unknown'; + +export interface OSDetectionResult { + readonly os: OperatingSystem; + readonly isMac: boolean; + readonly isWindows: boolean; + readonly isLinux: boolean; +} + +function detectOS(): OperatingSystem { + // Check Electron's process.platform first (most reliable in Electron apps) + if (typeof process !== 'undefined' && process.platform) { + if (process.platform === 'darwin') return 'mac'; + if (process.platform === 'win32') return 'windows'; + if (process.platform === 'linux') return 'linux'; + } + + if (typeof navigator === 'undefined') { + return 'unknown'; + } + + // Fallback: use modern userAgentData API with fallback to navigator.platform + const nav = navigator as Navigator & { userAgentData?: { platform: string } }; + const platform = (nav.userAgentData?.platform ?? navigator.platform ?? '').toLowerCase(); + + if (platform.includes('mac')) return 'mac'; + if (platform.includes('win')) return 'windows'; + if (platform.includes('linux') || platform.includes('x11')) return 'linux'; + return 'unknown'; +} + +/** + * Hook to detect the user's operating system. + * Returns OS information and convenience boolean flags. + */ +export function useOSDetection(): OSDetectionResult { + return useMemo(() => { + const os = detectOS(); + return { + os, + isMac: os === 'mac', + isWindows: os === 'windows', + isLinux: os === 'linux', + }; + }, []); +}