Merge pull request #268 from illia1f/feature/path-input-search

feat: Add search functionality to PathInput with keyboard shortcut support
This commit is contained in:
Web Dev Cody
2025-12-26 18:12:43 -05:00
committed by GitHub
5 changed files with 315 additions and 85 deletions

View File

@@ -10,8 +10,10 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PathInput } from '@/components/ui/path-input'; import { PathInput } from '@/components/ui/path-input';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage'; import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks';
interface DirectoryEntry { interface DirectoryEntry {
name: string; name: string;
@@ -67,6 +69,7 @@ export function FileBrowserDialog({
description = 'Navigate to your project folder or paste a path directly', description = 'Navigate to your project folder or paste a path directly',
initialPath, initialPath,
}: FileBrowserDialogProps) { }: FileBrowserDialogProps) {
const { isMac } = useOSDetection();
const [currentPath, setCurrentPath] = useState<string>(''); const [currentPath, setCurrentPath] = useState<string>('');
const [parentPath, setParentPath] = useState<string | null>(null); const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]); const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
@@ -232,7 +235,7 @@ export function FileBrowserDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4"> <DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none">
<DialogHeader className="pb-1"> <DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-4 h-4 text-brand-500" /> <FolderOpen className="w-4 h-4 text-brand-500" />
@@ -252,6 +255,12 @@ export function FileBrowserDialog({
error={!!error} error={!!error}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onHome={handleGoHome} onHome={handleGoHome}
entries={directories.map((dir) => ({ ...dir, isDirectory: true }))}
onSelectEntry={(entry) => {
if (entry.isDirectory) {
handleSelectDirectory(entry);
}
}}
/> />
{/* Recent folders */} {/* Recent folders */}
@@ -366,12 +375,10 @@ export function FileBrowserDialog({
> >
<FolderOpen className="w-3.5 h-3.5 mr-1.5" /> <FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder Select Current Folder
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border"> <KbdGroup className="ml-1">
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') <Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
? '⌘' <Kbd></Kbd>
: 'Ctrl'} </KbdGroup>
+
</kbd>
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10',
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -1,8 +1,9 @@
import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import { useState, useRef, useCallback, useMemo } 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Kbd } from '@/components/ui/kbd';
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbList, BreadcrumbList,
@@ -11,6 +12,14 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface BreadcrumbSegment { interface BreadcrumbSegment {
@@ -52,6 +61,12 @@ function parseBreadcrumbs(path: string): BreadcrumbSegment[] {
}); });
} }
interface FileSystemEntry {
name: string;
path: string;
isDirectory: boolean;
}
interface PathInputProps { interface PathInputProps {
/** Current resolved path */ /** Current resolved path */
currentPath: string; currentPath: string;
@@ -63,12 +78,18 @@ interface PathInputProps {
error?: boolean; error?: boolean;
/** Placeholder text for the input field */ /** Placeholder text for the input field */
placeholder?: string; 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) */ /** Called when user navigates to a path (via breadcrumb click, enter key, or navigation buttons) */
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
/** Called when user clicks home button (navigates to home directory) */ /** Called when user clicks home button (navigates to home directory) */
onHome: () => void; onHome: () => void;
/** Additional className for the container */ /** Additional className for the container */
className?: string; 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({ function PathInput({
@@ -77,11 +98,15 @@ function PathInput({
loading = false, loading = false,
error, error,
placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)', placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)',
searchPlaceholder = 'Search...',
onNavigate, onNavigate,
onHome, onHome,
className, className,
entries = [],
onSelectEntry,
}: PathInputProps) { }: PathInputProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [pathInput, setPathInput] = useState(currentPath); const [pathInput, setPathInput] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -163,6 +188,7 @@ function PathInput({
// Don't trigger if clicking on a button or already editing // Don't trigger if clicking on a button or already editing
if ( if (
isEditing || isEditing ||
isSearchOpen ||
(e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('button') ||
(e.target as HTMLElement).closest('a') (e.target as HTMLElement).closest('a')
) { ) {
@@ -170,11 +196,79 @@ function PathInput({
} }
setIsEditing(true); 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 breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]);
const entryItems = useMemo(
() =>
entries.map((entry) => (
<CommandItem key={entry.path} value={entry.name} onSelect={() => handleSelectEntry(entry)}>
{entry.isDirectory ? (
<Folder className="w-3.5 h-3.5 text-brand-500 mr-2" />
) : (
<File className="w-3.5 h-3.5 text-muted-foreground mr-2" />
)}
<span className="flex-1 truncate font-mono text-xs">{entry.name}</span>
</CommandItem>
)),
[entries, handleSelectEntry]
);
const showBreadcrumbs = currentPath && !isEditing && !loading && !error; const showBreadcrumbs = currentPath && !isEditing && !loading && !error;
return ( return (
@@ -210,87 +304,139 @@ function PathInput({
</div> </div>
{/* Path display / input */} {/* Path display / input */}
<div <div className="flex-1 relative min-w-0">
onClick={handleContainerClick} {/* Search Popover - positioned to overlap the input */}
className={cn( {isSearchOpen && (
'flex-1 flex items-center gap-2 min-w-0 h-8 px-3 rounded-md border bg-background/50 transition-colors', <div className="absolute inset-0 z-50">
error <div className="relative w-full h-full">
? 'border-destructive focus-within:border-destructive' <div className="absolute inset-0 bg-popover border border-border rounded-md shadow-lg">
: 'border-input focus-within:border-ring focus-within:ring-1 focus-within:ring-ring', <Command className="h-auto max-h-[300px]">
!isEditing && !error && 'cursor-text hover:border-ring/50' <div className="flex items-center gap-2 px-3 **:data-[slot=command-input-wrapper]:border-0 **:data-[slot=command-input-wrapper]:px-0">
<CommandInput
autoFocus
placeholder={searchPlaceholder}
className="h-8 flex-1"
/>
<div className="flex items-center gap-1 shrink-0 ml-auto">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSearchOpen(false)}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Close search"
>
<X className="w-3.5 h-3.5" />
</Button>
<Kbd className="hidden py-0.5 sm:inline-block">ESC</Kbd>
</div>
</div>
<CommandList className="scrollbar-styled">
<CommandEmpty>No files or directories found</CommandEmpty>
<CommandGroup>{entryItems}</CommandGroup>
</CommandList>
</Command>
</div>
</div>
</div>
)} )}
>
{showBreadcrumbs ? ( <div
<> onClick={handleContainerClick}
<Breadcrumb className="flex-1 min-w-0 overflow-hidden"> className={cn(
<BreadcrumbList className="flex-nowrap overflow-x-auto scrollbar-none"> 'flex items-center gap-2 min-w-0 h-8 px-3 rounded-md border bg-background/50 transition-colors',
{breadcrumbs.map((crumb) => ( error
<Fragment key={crumb.path}> ? 'border-destructive focus-within:border-destructive'
<BreadcrumbItem className="shrink-0"> : 'border-input focus-within:border-ring focus-within:ring-1 focus-within:ring-ring',
{crumb.isLast ? ( !isEditing && !error && 'cursor-text hover:border-ring/50'
<BreadcrumbPage className="font-mono text-xs font-medium truncate max-w-[200px]"> )}
{crumb.name} >
</BreadcrumbPage> {showBreadcrumbs ? (
) : ( <>
<BreadcrumbLink <Breadcrumb className="flex-1 min-w-0 overflow-hidden">
href="#" <BreadcrumbList className="flex-nowrap overflow-x-auto scrollbar-none">
onClick={(e) => { {breadcrumbs.map((crumb) => (
e.preventDefault(); <Fragment key={crumb.path}>
handleBreadcrumbClick(crumb.path); <BreadcrumbItem className="shrink-0">
}} {crumb.isLast ? (
className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]" <BreadcrumbPage className="font-mono text-xs font-medium truncate max-w-[200px]">
> {crumb.name}
{crumb.name} </BreadcrumbPage>
</BreadcrumbLink> ) : (
<BreadcrumbLink
href="#"
onClick={(e) => {
e.preventDefault();
handleBreadcrumbClick(crumb.path);
}}
className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]"
>
{crumb.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && (
<BreadcrumbSeparator className="[&>svg]:size-3.5 shrink-0" />
)} )}
</BreadcrumbItem> </Fragment>
{!crumb.isLast && <BreadcrumbSeparator className="[&>svg]:size-3.5 shrink-0" />} ))}
</Fragment> </BreadcrumbList>
))} </Breadcrumb>
</BreadcrumbList> <div className="flex items-center gap-0.5 shrink-0">
</Breadcrumb> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" onClick={() => setIsSearchOpen(true)}
onClick={handleStartEditing} className="h-6 w-6 text-muted-foreground hover:text-foreground"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" aria-label="Search files and directories"
aria-label="Edit path" title="Search files and directories"
> disabled={loading || entries.length === 0}
<Pencil className="w-3.5 h-3.5" /> >
</Button> <Search className="w-3.5 h-3.5" />
</> </Button>
) : ( <Button
<> variant="ghost"
<Input size="icon"
ref={inputRef} onClick={handleStartEditing}
type="text" className="h-6 w-6 text-muted-foreground hover:text-foreground"
placeholder={placeholder} aria-label="Edit path"
value={pathInput} >
onChange={(e) => setPathInput(e.target.value)} <Pencil className="w-3.5 h-3.5" />
onKeyDown={handleKeyDown} </Button>
onBlur={handleInputBlur} </div>
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" <Input
aria-invalid={error} ref={inputRef}
/> type="text"
<Button placeholder={placeholder}
variant="ghost" value={pathInput}
size="icon" onChange={(e) => setPathInput(e.target.value)}
onClick={handleGoToPath} onKeyDown={handleKeyDown}
disabled={!pathInput.trim() || loading} onBlur={handleInputBlur}
className="h-6 w-6 shrink-0" className="flex-1 font-mono text-xs h-7 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent"
aria-label="Go to path" data-testid="path-input"
> disabled={loading}
<ArrowRight className="w-3.5 h-3.5" /> aria-label="Path input"
</Button> aria-invalid={error}
</> />
)} <Button
variant="ghost"
size="icon"
onClick={handleGoToPath}
disabled={!pathInput.trim() || loading}
className="h-6 w-6 shrink-0"
aria-label="Go to path"
>
<ArrowRight className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div> </div>
</div> </div>
); );
} }
export { PathInput, parseBreadcrumbs }; export { PathInput, parseBreadcrumbs };
export type { PathInputProps, BreadcrumbSegment }; export type { PathInputProps, BreadcrumbSegment, FileSystemEntry };

View File

@@ -3,6 +3,7 @@ export { useBoardBackgroundSettings } from './use-board-background-settings';
export { useElectronAgent } from './use-electron-agent'; export { useElectronAgent } from './use-electron-agent';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
export { useMessageQueue } from './use-message-queue'; export { useMessageQueue } from './use-message-queue';
export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection';
export { useResponsiveKanban } from './use-responsive-kanban'; export { useResponsiveKanban } from './use-responsive-kanban';
export { useScrollTracking } from './use-scroll-tracking'; export { useScrollTracking } from './use-scroll-tracking';
export { useSettingsMigration } from './use-settings-migration'; export { useSettingsMigration } from './use-settings-migration';

View File

@@ -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',
};
}, []);
}