import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { Box, Dialog, InputAdornment, useTheme } from '@mui/material' import { Search } from '@mui/icons-material' import { useCommonStore } from '../stores/common.store' import { useI18nStore } from '../stores/i18n.store' import { getShortcuts, ShortcutDef } from '../stores/shortcuts' interface PaletteItem { label: string description: string shortcut: ShortcutDef } export default function CommandPaletteDialog() { const open = useCommonStore(s => s.commandPaletteOpen) const setOpen = useCommonStore(s => s.setCommandPaletteOpen) const strings = useI18nStore(s => s.strings) const theme = useTheme() const isDark = theme.palette.mode === 'dark' const [search, setSearch] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const listRef = useRef(null) const allItems = useMemo((): PaletteItem[] => { const seen = new Set() const items: PaletteItem[] = [] for (const s of getShortcuts()) { if (seen.has(s.descriptionKey)) continue seen.add(s.descriptionKey) items.push({ label: s.label, description: strings?.label?.[s.descriptionKey] || s.descriptionKey, shortcut: s, }) } return items }, [strings]) const filtered = useMemo(() => { const q = search.toLowerCase().trim() if (!q) return allItems return allItems.filter(i => i.description.toLowerCase().includes(q) || i.label.toLowerCase().includes(q) ) }, [search, allItems]) useEffect(() => { if (open) { setSearch('') setSelectedIndex(0) setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) // Scroll selected item into view useEffect(() => { if (!open || !listRef.current) return const items = listRef.current.querySelectorAll('.p3xr-cmd-palette-item') items[selectedIndex]?.scrollIntoView({ block: 'nearest' }) }, [selectedIndex, open]) const execute = useCallback((item: PaletteItem) => { setOpen(false) item.shortcut.action() }, [setOpen]) const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex(prev => Math.max(prev - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault() if (filtered[selectedIndex]) execute(filtered[selectedIndex]) } else if (e.key === 'Escape') { setOpen(false) } }, [filtered, selectedIndex, execute, setOpen]) if (!open) return null const hoverBg = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)' const activeBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)' return ( setOpen(false)} slotProps={{ paper: { sx: { width: '100%', maxWidth: 500, minWidth: 360, borderRadius: 2, overflow: 'hidden', }, }, }}> ) => { setSearch(e.target.value) setSelectedIndex(0) }} onKeyDown={onKeyDown} placeholder={strings?.label?.commandPalette || 'Command Palette'} autoComplete="off" sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'text.primary', fontSize: 16, fontFamily: 'inherit', '&::placeholder': { color: 'text.secondary', opacity: 0.5 }, }} /> {filtered.map((item, i) => ( execute(item)} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, py: 1.25, cursor: 'pointer', bgcolor: i === selectedIndex ? activeBg : 'transparent', '&:hover': { bgcolor: hoverBg }, }} > {item.description} {item.label} ))} {filtered.length === 0 && ( {strings?.label?.noResults || 'No results'} )} ) }