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:
parent
0cdd22a3cb
commit
538c1ff44b
3 changed files with 406 additions and 4 deletions
50
src/App.tsx
50
src/App.tsx
|
|
@ -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"
|
||||
|
|
|
|||
205
src/components/CommandPalette.tsx
Normal file
205
src/components/CommandPalette.tsx
Normal 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'],
|
||||
},
|
||||
];
|
||||
155
src/components/FileSearchModal.tsx
Normal file
155
src/components/FileSearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue