aethex-studio/src/components/FileSearchModal.tsx
Claude 538c1ff44b
Add File Search Modal (Cmd+P) and Command Palette (Cmd+K)
Implemented two powerful productivity features:
- File Search Modal: Quick file navigation with fuzzy search (Cmd/Ctrl+P)
- Command Palette: Searchable command system for common actions (Cmd/Ctrl+K)

Both features include keyboard navigation and integrate seamlessly with existing shortcuts.
2026-01-17 22:15:06 +00:00

155 lines
5.4 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { Dialog, DialogContent } from './ui/dialog';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { FileNode } from './FileTree';
import { MagnifyingGlass, File, Folder } from '@phosphor-icons/react';
interface FileSearchModalProps {
open: boolean;
onClose: () => void;
files: FileNode[];
onFileSelect: (file: FileNode) => void;
}
export function FileSearchModal({ open, onClose, files, onFileSelect }: FileSearchModalProps) {
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
// Flatten file tree to searchable list
const flattenFiles = (nodes: FileNode[], path = ''): Array<{ file: FileNode; path: string }> => {
let result: Array<{ file: FileNode; path: string }> = [];
for (const node of nodes) {
const currentPath = path ? `${path}/${node.name}` : node.name;
if (node.type === 'file') {
result.push({ file: node, path: currentPath });
}
if (node.children) {
result = [...result, ...flattenFiles(node.children, currentPath)];
}
}
return result;
};
const allFiles = useMemo(() => flattenFiles(files), [files]);
// Filter files based on search
const filteredFiles = useMemo(() => {
if (!search) return allFiles;
const searchLower = search.toLowerCase();
return allFiles.filter(({ file, path }) =>
path.toLowerCase().includes(searchLower) ||
file.name.toLowerCase().includes(searchLower)
);
}, [allFiles, search]);
// Reset when opened/closed
useEffect(() => {
if (open) {
setSearch('');
setSelectedIndex(0);
}
}, [open]);
// Keyboard navigation
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filteredFiles.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filteredFiles[selectedIndex]) {
handleSelect(filteredFiles[selectedIndex].file);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, filteredFiles, selectedIndex]);
const handleSelect = (file: FileNode) => {
onFileSelect(file);
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl p-0 gap-0">
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
<MagnifyingGlass className="text-muted-foreground" size={20} />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSelectedIndex(0);
}}
placeholder="Search files... (type to filter)"
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-base"
autoFocus
/>
</div>
<ScrollArea className="max-h-[400px]">
{filteredFiles.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<File size={48} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No files found</p>
{search && (
<p className="text-xs mt-1">Try a different search term</p>
)}
</div>
) : (
<div className="py-2">
{filteredFiles.map(({ file, path }, index) => (
<button
key={file.id}
onClick={() => handleSelect(file)}
className={`w-full px-4 py-2.5 flex items-center gap-3 hover:bg-accent/50 transition-colors ${
index === selectedIndex ? 'bg-accent/50' : ''
}`}
onMouseEnter={() => setSelectedIndex(index)}
>
<File size={18} className="text-muted-foreground flex-shrink-0" />
<div className="flex-1 text-left overflow-hidden">
<div className="text-sm font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground truncate">{path}</div>
</div>
{index === selectedIndex && (
<kbd className="px-2 py-1 text-xs bg-muted rounded"></kbd>
)}
</button>
))}
</div>
)}
</ScrollArea>
<div className="px-4 py-2 border-t border-border bg-muted/30 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-background rounded"></kbd> Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-background rounded"></kbd> Select
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-background rounded">Esc</kbd> Close
</span>
</div>
<span>{filteredFiles.length} files</span>
</div>
</DialogContent>
</Dialog>
);
}