mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
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.
This commit is contained in:
@@ -10,6 +10,7 @@ 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';
|
||||||
|
|
||||||
@@ -232,7 +233,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 +253,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 +373,14 @@ 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>
|
||||||
? '⌘'
|
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
|
||||||
: 'Ctrl'}
|
? '⌘'
|
||||||
+↵
|
: 'Ctrl'}
|
||||||
</kbd>
|
</Kbd>
|
||||||
|
<Kbd>↵</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 {
|
import {
|
||||||
@@ -11,6 +11,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 +60,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 +77,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 +97,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);
|
||||||
@@ -103,6 +127,21 @@ function PathInput({
|
|||||||
}
|
}
|
||||||
}, [error, isEditing]);
|
}, [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(() => {
|
const handleGoToParent = useCallback(() => {
|
||||||
if (parentPath) {
|
if (parentPath) {
|
||||||
onNavigate(parentPath);
|
onNavigate(parentPath);
|
||||||
@@ -163,6 +202,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,9 +210,59 @@ 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)
|
||||||
|
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 breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]);
|
||||||
|
|
||||||
const showBreadcrumbs = currentPath && !isEditing && !loading && !error;
|
const showBreadcrumbs = currentPath && !isEditing && !loading && !error;
|
||||||
@@ -210,87 +300,152 @@ 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 placeholder={searchPlaceholder} className="h-8 flex-1" />
|
||||||
>
|
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
||||||
{showBreadcrumbs ? (
|
<Button
|
||||||
<>
|
variant="ghost"
|
||||||
<Breadcrumb className="flex-1 min-w-0 overflow-hidden">
|
size="icon"
|
||||||
<BreadcrumbList className="flex-nowrap overflow-x-auto scrollbar-none">
|
onClick={() => setIsSearchOpen(false)}
|
||||||
{breadcrumbs.map((crumb) => (
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
<Fragment key={crumb.path}>
|
aria-label="Close search"
|
||||||
<BreadcrumbItem className="shrink-0">
|
>
|
||||||
{crumb.isLast ? (
|
<X className="w-3.5 h-3.5" />
|
||||||
<BreadcrumbPage className="font-mono text-xs font-medium truncate max-w-[200px]">
|
</Button>
|
||||||
{crumb.name}
|
<kbd className="hidden sm:inline-block px-1.5 py-0.5 text-[10px] bg-muted rounded border border-border text-muted-foreground">
|
||||||
</BreadcrumbPage>
|
ESC
|
||||||
) : (
|
</kbd>
|
||||||
<BreadcrumbLink
|
</div>
|
||||||
href="#"
|
</div>
|
||||||
onClick={(e) => {
|
<CommandList>
|
||||||
e.preventDefault();
|
<CommandEmpty>No files or directories found</CommandEmpty>
|
||||||
handleBreadcrumbClick(crumb.path);
|
<CommandGroup>
|
||||||
}}
|
{entries.map((entry) => (
|
||||||
className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]"
|
<CommandItem
|
||||||
|
key={entry.path}
|
||||||
|
value={entry.name}
|
||||||
|
onSelect={() => handleSelectEntry(entry)}
|
||||||
>
|
>
|
||||||
{crumb.name}
|
{entry.isDirectory ? (
|
||||||
</BreadcrumbLink>
|
<Folder className="w-3.5 h-3.5 text-brand-500 mr-2" />
|
||||||
)}
|
) : (
|
||||||
</BreadcrumbItem>
|
<File className="w-3.5 h-3.5 text-muted-foreground mr-2" />
|
||||||
{!crumb.isLast && <BreadcrumbSeparator className="[&>svg]:size-3.5 shrink-0" />}
|
)}
|
||||||
</Fragment>
|
<span className="flex-1 truncate font-mono text-xs">{entry.name}</span>
|
||||||
))}
|
</CommandItem>
|
||||||
</BreadcrumbList>
|
))}
|
||||||
</Breadcrumb>
|
</CommandGroup>
|
||||||
<Button
|
</CommandList>
|
||||||
variant="ghost"
|
</Command>
|
||||||
size="icon"
|
</div>
|
||||||
onClick={handleStartEditing}
|
</div>
|
||||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
</div>
|
||||||
aria-label="Edit path"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={pathInput}
|
|
||||||
onChange={(e) => 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}
|
|
||||||
/>
|
|
||||||
<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
|
||||||
|
onClick={handleContainerClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 min-w-0 h-8 px-3 rounded-md border bg-background/50 transition-colors',
|
||||||
|
error
|
||||||
|
? 'border-destructive focus-within:border-destructive'
|
||||||
|
: 'border-input focus-within:border-ring focus-within:ring-1 focus-within:ring-ring',
|
||||||
|
!isEditing && !error && 'cursor-text hover:border-ring/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showBreadcrumbs ? (
|
||||||
|
<>
|
||||||
|
<Breadcrumb className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<BreadcrumbList className="flex-nowrap overflow-x-auto scrollbar-none">
|
||||||
|
{breadcrumbs.map((crumb) => (
|
||||||
|
<Fragment key={crumb.path}>
|
||||||
|
<BreadcrumbItem className="shrink-0">
|
||||||
|
{crumb.isLast ? (
|
||||||
|
<BreadcrumbPage className="font-mono text-xs font-medium truncate max-w-[200px]">
|
||||||
|
{crumb.name}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsSearchOpen(true)}
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Search files and directories"
|
||||||
|
title="Search files and directories"
|
||||||
|
disabled={loading || entries.length === 0}
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Edit path"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={pathInput}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user