mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Merge pull request #262 from illia1f/refactor/file-path-input
refactor: Extract PathInput component from FileBrowserDialog & Improve UI/UX
This commit is contained in:
@@ -1,15 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { FolderOpen, Folder, ChevronRight, HardDrive, Clock, X } from 'lucide-react';
|
||||||
FolderOpen,
|
|
||||||
Folder,
|
|
||||||
ChevronRight,
|
|
||||||
Home,
|
|
||||||
ArrowLeft,
|
|
||||||
HardDrive,
|
|
||||||
CornerDownLeft,
|
|
||||||
Clock,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,7 +9,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { getJSON, setJSON } from '@/lib/storage';
|
||||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||||
|
|
||||||
@@ -78,7 +68,6 @@ export function FileBrowserDialog({
|
|||||||
initialPath,
|
initialPath,
|
||||||
}: FileBrowserDialogProps) {
|
}: FileBrowserDialogProps) {
|
||||||
const [currentPath, setCurrentPath] = useState<string>('');
|
const [currentPath, setCurrentPath] = useState<string>('');
|
||||||
const [pathInput, setPathInput] = 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[]>([]);
|
||||||
const [drives, setDrives] = useState<string[]>([]);
|
const [drives, setDrives] = useState<string[]>([]);
|
||||||
@@ -86,7 +75,6 @@ export function FileBrowserDialog({
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [warning, setWarning] = useState('');
|
const [warning, setWarning] = useState('');
|
||||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Load recent folders when dialog opens
|
// Load recent folders when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,7 +108,6 @@ export function FileBrowserDialog({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setCurrentPath(result.currentPath);
|
setCurrentPath(result.currentPath);
|
||||||
setPathInput(result.currentPath);
|
|
||||||
setParentPath(result.parentPath);
|
setParentPath(result.parentPath);
|
||||||
setDirectories(result.directories);
|
setDirectories(result.directories);
|
||||||
setDrives(result.drives || []);
|
setDrives(result.drives || []);
|
||||||
@@ -142,11 +129,10 @@ export function FileBrowserDialog({
|
|||||||
[browseDirectory]
|
[browseDirectory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset current path when dialog closes
|
// Reset state when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCurrentPath('');
|
setCurrentPath('');
|
||||||
setPathInput('');
|
|
||||||
setParentPath(null);
|
setParentPath(null);
|
||||||
setDirectories([]);
|
setDirectories([]);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -172,9 +158,6 @@ export function FileBrowserDialog({
|
|||||||
const pathToUse = initialPath || defaultDir;
|
const pathToUse = initialPath || defaultDir;
|
||||||
|
|
||||||
if (pathToUse) {
|
if (pathToUse) {
|
||||||
// Pre-fill the path input immediately
|
|
||||||
setPathInput(pathToUse);
|
|
||||||
// Then browse to that directory
|
|
||||||
browseDirectory(pathToUse);
|
browseDirectory(pathToUse);
|
||||||
} else {
|
} else {
|
||||||
// No default directory, browse home directory
|
// No default directory, browse home directory
|
||||||
@@ -183,7 +166,6 @@ export function FileBrowserDialog({
|
|||||||
} catch {
|
} catch {
|
||||||
// If config fetch fails, try initialPath or fall back to home directory
|
// If config fetch fails, try initialPath or fall back to home directory
|
||||||
if (initialPath) {
|
if (initialPath) {
|
||||||
setPathInput(initialPath);
|
|
||||||
browseDirectory(initialPath);
|
browseDirectory(initialPath);
|
||||||
} else {
|
} else {
|
||||||
browseDirectory();
|
browseDirectory();
|
||||||
@@ -199,34 +181,21 @@ export function FileBrowserDialog({
|
|||||||
browseDirectory(dir.path);
|
browseDirectory(dir.path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoToParent = () => {
|
const handleGoHome = useCallback(() => {
|
||||||
if (parentPath) {
|
|
||||||
browseDirectory(parentPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoHome = () => {
|
|
||||||
browseDirectory();
|
browseDirectory();
|
||||||
};
|
}, [browseDirectory]);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
browseDirectory(path);
|
||||||
|
},
|
||||||
|
[browseDirectory]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectDrive = (drivePath: string) => {
|
const handleSelectDrive = (drivePath: string) => {
|
||||||
browseDirectory(drivePath);
|
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(() => {
|
const handleSelect = useCallback(() => {
|
||||||
if (currentPath) {
|
if (currentPath) {
|
||||||
addRecentFolder(currentPath);
|
addRecentFolder(currentPath);
|
||||||
@@ -275,31 +244,15 @@ export function FileBrowserDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
||||||
{/* Direct path input */}
|
{/* Path navigation */}
|
||||||
<div className="flex items-center gap-1.5">
|
<PathInput
|
||||||
<Input
|
currentPath={currentPath}
|
||||||
ref={pathInputRef}
|
parentPath={parentPath}
|
||||||
type="text"
|
loading={loading}
|
||||||
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
|
error={!!error}
|
||||||
value={pathInput}
|
onNavigate={handleNavigate}
|
||||||
onChange={(e) => setPathInput(e.target.value)}
|
onHome={handleGoHome}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Recent folders */}
|
{/* Recent folders */}
|
||||||
{recentFolders.length > 0 && (
|
{recentFolders.length > 0 && (
|
||||||
@@ -352,35 +305,8 @@ export function FileBrowserDialog({
|
|||||||
</div>
|
</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 */}
|
{/* 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 && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-xs text-muted-foreground">Loading directories...</div>
|
<div className="text-xs text-muted-foreground">Loading directories...</div>
|
||||||
@@ -423,8 +349,8 @@ export function FileBrowserDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
Paste a full path above, or click on folders to navigate. Press Enter or click Go to
|
Paste a full path above, or click on folders to navigate. Press Enter or click → to jump
|
||||||
jump to a path.
|
to a path.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
102
apps/ui/src/components/ui/breadcrumb.tsx
Normal file
102
apps/ui/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn('inline-flex items-center gap-1.5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'a'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'a';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn('hover:text-foreground transition-colors', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn('text-foreground font-normal', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('[&>svg]:size-3.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('flex size-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
296
apps/ui/src/components/ui/path-input.tsx
Normal file
296
apps/ui/src/components/ui/path-input.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { Home, ArrowLeft, Pencil, ArrowRight } 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 [];
|
||||||
|
|
||||||
|
// Handle root path on Unix-like systems
|
||||||
|
if (path === '/') {
|
||||||
|
return [{ name: '/', path: '/', isLast: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PathInput, parseBreadcrumbs };
|
||||||
|
export type { PathInputProps, BreadcrumbSegment };
|
||||||
Reference in New Issue
Block a user