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 { FileTree, FileNode } from './components/FileTree';
|
||||||
import { FileTabs } from './components/FileTabs';
|
import { FileTabs } from './components/FileTabs';
|
||||||
import { ConsolePanel } from './components/ConsolePanel';
|
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 { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './components/ui/resizable';
|
||||||
import { useIsMobile } from './hooks/use-mobile';
|
import { useIsMobile } from './hooks/use-mobile';
|
||||||
import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts';
|
||||||
|
|
@ -30,6 +32,8 @@ function App() {
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [showNewProject, setShowNewProject] = useState(false);
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
|
const [showFileSearch, setShowFileSearch] = useState(false);
|
||||||
|
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
|
@ -61,8 +65,7 @@ function App() {
|
||||||
meta: true,
|
meta: true,
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
// TODO: Implement file search modal
|
setShowFileSearch(true);
|
||||||
toast.info('File search coming soon! (Cmd/Ctrl+P)');
|
|
||||||
captureEvent('keyboard_shortcut', { action: 'file_search' });
|
captureEvent('keyboard_shortcut', { action: 'file_search' });
|
||||||
},
|
},
|
||||||
description: 'Quick file search',
|
description: 'Quick file search',
|
||||||
|
|
@ -72,8 +75,7 @@ function App() {
|
||||||
meta: true,
|
meta: true,
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
// TODO: Implement command palette
|
setShowCommandPalette(true);
|
||||||
toast.info('Command palette coming soon! (Cmd/Ctrl+K)');
|
|
||||||
captureEvent('keyboard_shortcut', { action: 'command_palette' });
|
captureEvent('keyboard_shortcut', { action: 'command_palette' });
|
||||||
},
|
},
|
||||||
description: 'Command palette',
|
description: 'Command palette',
|
||||||
|
|
@ -435,6 +437,46 @@ end)`,
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<WelcomeDialog />
|
<WelcomeDialog />
|
||||||
</Suspense>
|
</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 && (
|
{!user && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
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