mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
refactor: streamline file browser dialog and introduce PathInput component
- Removed unused state and imports from FileBrowserDialog. - Replaced direct path input with a new PathInput component for improved navigation. - Enhanced state management for path navigation and error handling. - Updated UI elements for better user experience and code clarity.
This commit is contained in:
@@ -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<string>('');
|
||||
const [pathInput, setPathInput] = useState<string>('');
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
@@ -86,7 +75,6 @@ export function FileBrowserDialog({
|
||||
const [error, setError] = useState('');
|
||||
const [warning, setWarning] = useState('');
|
||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleGoToPath();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (currentPath) {
|
||||
addRecentFolder(currentPath);
|
||||
@@ -275,31 +244,15 @@ export function FileBrowserDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
||||
{/* Direct path input */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
type="text"
|
||||
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathInputKeyDown}
|
||||
className="flex-1 font-mono text-xs h-8"
|
||||
data-testid="path-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGoToPath}
|
||||
disabled={loading || !pathInput.trim()}
|
||||
data-testid="go-to-path-button"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
{/* Path navigation */}
|
||||
<PathInput
|
||||
currentPath={currentPath}
|
||||
parentPath={parentPath}
|
||||
loading={loading}
|
||||
error={!!error}
|
||||
onNavigate={handleNavigate}
|
||||
onHome={handleGoHome}
|
||||
/>
|
||||
|
||||
{/* Recent folders */}
|
||||
{recentFolders.length > 0 && (
|
||||
@@ -352,35 +305,8 @@ export function FileBrowserDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoHome}
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
||||
{currentPath || 'Loading...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">Loading directories...</div>
|
||||
|
||||
277
apps/ui/src/components/ui/path-input.tsx
Normal file
277
apps/ui/src/components/ui/path-input.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
role="navigation"
|
||||
aria-label="Path navigation"
|
||||
>
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onHome}
|
||||
className="h-7 w-7"
|
||||
disabled={loading}
|
||||
aria-label="Go to home directory"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleGoToParent}
|
||||
className="h-7 w-7"
|
||||
disabled={loading || !parentPath}
|
||||
aria-label="Go to parent directory"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Path display / input */}
|
||||
<div
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
'flex-1 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">
|
||||
{parseBreadcrumbs(currentPath).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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleStartEditing}
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PathInput, parseBreadcrumbs };
|
||||
export type { PathInputProps, BreadcrumbSegment };
|
||||
Reference in New Issue
Block a user