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.
This commit is contained in:
Claude 2026-01-17 22:15:06 +00:00
parent 0cdd22a3cb
commit 538c1ff44b
No known key found for this signature in database
3 changed files with 406 additions and 4 deletions

View file

@ -6,6 +6,8 @@ import { Toolbar } from './components/Toolbar';
import { FileTree, FileNode } from './components/FileTree';
import { FileTabs } from './components/FileTabs';
import { ConsolePanel } from './components/ConsolePanel';
import { FileSearchModal } from './components/FileSearchModal';
import { CommandPalette, createDefaultCommands } from './components/CommandPalette';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './components/ui/resizable';
import { useIsMobile } from './hooks/use-mobile';
import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts';
@ -30,6 +32,8 @@ function App() {
const [showTemplates, setShowTemplates] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [showFileSearch, setShowFileSearch] = useState(false);
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [code, setCode] = useState('');
const isMobile = useIsMobile();
@ -61,8 +65,7 @@ function App() {
meta: true,
ctrl: true,
handler: () => {
// TODO: Implement file search modal
toast.info('File search coming soon! (Cmd/Ctrl+P)');
setShowFileSearch(true);
captureEvent('keyboard_shortcut', { action: 'file_search' });
},
description: 'Quick file search',
@ -72,8 +75,7 @@ function App() {
meta: true,
ctrl: true,
handler: () => {
// TODO: Implement command palette
toast.info('Command palette coming soon! (Cmd/Ctrl+K)');
setShowCommandPalette(true);
captureEvent('keyboard_shortcut', { action: 'command_palette' });
},
description: 'Command palette',
@ -435,6 +437,46 @@ end)`,
<Suspense fallback={null}>
<WelcomeDialog />
</Suspense>
{/* File Search Modal (Cmd/Ctrl+P) */}
<FileSearchModal
open={showFileSearch}
onClose={() => setShowFileSearch(false)}
files={files || []}
onFileSelect={handleFileSelect}
/>
{/* Command Palette (Cmd/Ctrl+K) */}
<CommandPalette
open={showCommandPalette}
onClose={() => setShowCommandPalette(false)}
commands={createDefaultCommands({
onNewProject: () => setShowNewProject(true),
onTemplates: () => setShowTemplates(true),
onPreview: () => setShowPreview(true),
onExport: async () => {
const blob = new Blob([currentCode], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'script.lua';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Script exported!');
},
onCopy: async () => {
try {
await navigator.clipboard.writeText(currentCode);
toast.success('Code copied to clipboard!');
} catch (error) {
toast.error('Failed to copy code');
}
},
})}
/>
{!user && (
<Button
variant="secondary"

View file

@ -0,0 +1,205 @@
import { useState, useEffect, useMemo } from 'react';
import { Dialog, DialogContent } from './ui/dialog';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import {
MagnifyingGlass,
FileCode,
FolderPlus,
Play,
Copy,
Download,
Trash,
Rocket,
Bug,
} from '@phosphor-icons/react';
export interface Command {
id: string;
label: string;
description: string;
icon: React.ReactNode;
action: () => void;
keywords?: string[];
}
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
commands: Command[];
}
export function CommandPalette({ open, onClose, commands }: CommandPaletteProps) {
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
// Filter commands based on search
const filteredCommands = useMemo(() => {
if (!search) return commands;
const searchLower = search.toLowerCase();
return commands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(searchLower) ||
cmd.description.toLowerCase().includes(searchLower) ||
cmd.keywords?.some((k) => k.toLowerCase().includes(searchLower))
);
}, [commands, 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, filteredCommands.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filteredCommands[selectedIndex]) {
handleExecute(filteredCommands[selectedIndex]);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, filteredCommands, selectedIndex]);
const handleExecute = (command: Command) => {
command.action();
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">
<Rocket className="text-muted-foreground" size={20} />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSelectedIndex(0);
}}
placeholder="Type a command or search..."
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-base"
autoFocus
/>
</div>
<ScrollArea className="max-h-[400px]">
{filteredCommands.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<MagnifyingGlass size={48} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No commands found</p>
{search && (
<p className="text-xs mt-1">Try a different search term</p>
)}
</div>
) : (
<div className="py-2">
{filteredCommands.map((command, index) => (
<button
key={command.id}
onClick={() => handleExecute(command)}
className={`w-full px-4 py-3 flex items-center gap-3 hover:bg-accent/50 transition-colors ${
index === selectedIndex ? 'bg-accent/50' : ''
}`}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex-shrink-0 text-accent">{command.icon}</div>
<div className="flex-1 text-left">
<div className="text-sm font-medium">{command.label}</div>
<div className="text-xs text-muted-foreground">
{command.description}
</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> Execute
</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>{filteredCommands.length} commands</span>
</div>
</DialogContent>
</Dialog>
);
}
// Predefined command templates
export const createDefaultCommands = (actions: {
onNewProject: () => void;
onTemplates: () => void;
onPreview: () => void;
onExport: () => void;
onCopy: () => void;
}): Command[] => [
{
id: 'new-project',
label: 'New Project',
description: 'Create a new project from template',
icon: <FolderPlus size={20} />,
action: actions.onNewProject,
keywords: ['create', 'start', 'begin'],
},
{
id: 'templates',
label: 'Browse Templates',
description: 'View and select code templates',
icon: <FileCode size={20} />,
action: actions.onTemplates,
keywords: ['snippets', 'examples', 'boilerplate'],
},
{
id: 'preview',
label: 'Preview All Platforms',
description: 'Open multi-platform preview',
icon: <Play size={20} />,
action: actions.onPreview,
keywords: ['run', 'test', 'demo'],
},
{
id: 'copy',
label: 'Copy Code',
description: 'Copy current code to clipboard',
icon: <Copy size={20} />,
action: actions.onCopy,
keywords: ['clipboard', 'paste'],
},
{
id: 'export',
label: 'Export Script',
description: 'Download code as .lua file',
icon: <Download size={20} />,
action: actions.onExport,
keywords: ['download', 'save', 'file'],
},
];

View file

@ -0,0 +1,155 @@
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>
);
}