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.
155 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|