mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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<string>('');
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
@@ -232,7 +235,7 @@ export function FileBrowserDialog({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FolderOpen className="w-4 h-4 text-brand-500" />
|
||||
@@ -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({
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
|
||||
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
|
||||
? '⌘'
|
||||
: 'Ctrl'}
|
||||
+↵
|
||||
</kbd>
|
||||
<KbdGroup className="ml-1">
|
||||
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
|
||||
<Kbd>↵</Kbd>
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
28
apps/ui/src/components/ui/kbd.tsx
Normal file
28
apps/ui/src/components/ui/kbd.tsx
Normal 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 };
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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) => (
|
||||
<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;
|
||||
|
||||
return (
|
||||
@@ -210,87 +304,139 @@ function PathInput({
|
||||
</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'
|
||||
<div className="flex-1 relative min-w-0">
|
||||
{/* Search Popover - positioned to overlap the input */}
|
||||
{isSearchOpen && (
|
||||
<div className="absolute inset-0 z-50">
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute inset-0 bg-popover border border-border rounded-md shadow-lg">
|
||||
<Command className="h-auto max-h-[300px]">
|
||||
<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 ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export { PathInput, parseBreadcrumbs };
|
||||
export type { PathInputProps, BreadcrumbSegment };
|
||||
export type { PathInputProps, BreadcrumbSegment, FileSystemEntry };
|
||||
|
||||
@@ -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';
|
||||
|
||||
48
apps/ui/src/hooks/use-os-detection.ts
Normal file
48
apps/ui/src/hooks/use-os-detection.ts
Normal 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',
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user