import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/utils' import type { VaultEntry } from '../types' import { fuzzyMatch } from '../utils/fuzzyMatch' import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge' import type { NoteReference } from '../utils/ai-context' import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry' import { groupSortKey } from '../hooks/useCommandRegistry' import { localizeCommandGroup } from '../hooks/commands/localizeCommands' import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener' import { createTranslator, type AppLocale } from '../lib/i18n' import { formatDroppedPathList } from './inlineWikilinkDropText' import { CommandPaletteAiMode } from './CommandPaletteAiMode' import { Input } from './ui/input' import { useNativePathDrop } from './useNativePathDrop' interface CommandPaletteProps { open: boolean commands: CommandAction[] entries?: VaultEntry[] claudeCodeReady?: boolean aiAgentReady?: boolean aiAgentLabel?: string locale?: AppLocale onClose: () => void } interface ScoredCommand { command: CommandAction score: number } function focusPaletteTarget(target: HTMLInputElement | HTMLDivElement | null) { if (!target) return target.focus() if (!(target instanceof HTMLDivElement)) return const selection = window.getSelection() if (!selection) return const range = document.createRange() range.selectNodeContents(target) range.collapse(false) selection.removeAllRanges() selection.addRange(range) } function matchCommand(query: string, command: CommandAction): ScoredCommand | null { const labelResult = fuzzyMatch(query, command.label) if (labelResult.match) return { command, score: labelResult.score } for (const keyword of command.keywords ?? []) { const keywordResult = fuzzyMatch(query, keyword) if (keywordResult.match) return { command, score: keywordResult.score - 1 } } const groupResult = fuzzyMatch(query, command.group) if (groupResult.match) return { command, score: groupResult.score - 2 } return null } function groupResults( commands: CommandAction[], byRelevance: boolean, ): { group: CommandGroup; items: CommandAction[] }[] { const groupedCommands = new Map() for (const command of commands) { const existing = groupedCommands.get(command.group) if (existing) { existing.push(command) continue } groupedCommands.set(command.group, [command]) } const entries = Array.from(groupedCommands.entries()) if (!byRelevance) { entries.sort((left, right) => groupSortKey(left[0]) - groupSortKey(right[0])) } return entries.map(([group, items]) => ({ group, items })) } function usePaletteResults(commands: CommandAction[], query: string) { const enabledCommands = useMemo( () => commands.filter((command) => command.enabled), [commands], ) const filteredCommands = useMemo(() => { if (!query.trim()) return enabledCommands return enabledCommands .map((command) => matchCommand(query, command)) .filter((result): result is ScoredCommand => result !== null) .sort((left, right) => right.score - left.score) .map((result) => result.command) }, [enabledCommands, query]) const hasQuery = query.trim().length > 0 const groups = useMemo( () => groupResults(filteredCommands, hasQuery), [filteredCommands, hasQuery], ) return { groups, flatList: groups.flatMap((group) => group.items), } } function rememberCommandOpener( command: CommandAction, target: HTMLInputElement | HTMLDivElement | null, ) { if (command.id !== 'open-contribute') return rememberFeedbackDialogOpener(target instanceof HTMLElement ? target : null) } function inputSelectionRange(input: HTMLInputElement, fallbackIndex: number) { const start = input.selectionStart ?? fallbackIndex const end = input.selectionEnd ?? start return { start: Math.max(0, start), end: Math.max(start, end), } } function CommandPaletteInput({ inputRef, query, onChange, placeholder, }: { inputRef: React.RefObject query: string onChange: (value: string) => void placeholder: string }) { const insertNativePathDrop = (paths: string[]) => { const droppedPathText = formatDroppedPathList(paths) const input = inputRef.current if (!droppedPathText || !input) return const { start, end } = inputSelectionRange(input, query.length) const nextValue = `${query.slice(0, start)}${droppedPathText}${query.slice(end)}` const nextCursor = start + droppedPathText.length onChange(nextValue) window.requestAnimationFrame(() => { input.focus() input.setSelectionRange(nextCursor, nextCursor) }) } useNativePathDrop({ targetRef: inputRef, onPathDrop: insertNativePathDrop, }) return ( onChange(event.target.value)} /> ) } function CommandPaletteResults({ groups, selectedIndex, listRef, emptyText, locale, onHover, onSelect, }: { groups: { group: CommandGroup; items: CommandAction[] }[] selectedIndex: number listRef: React.RefObject emptyText: string locale: AppLocale onHover: (index: number) => void onSelect: (command: CommandAction) => void }) { const flatList = groups.flatMap((group) => group.items) if (flatList.length === 0) { return (
{emptyText}
) } const sections = groups.reduce>( (acc, group) => { const previous = acc.at(-1) acc.push({ ...group, startIndex: previous ? previous.startIndex + previous.items.length : 0, }) return acc }, [], ) return (
{sections.map(({ group, items, startIndex }) => { return (
{localizeCommandGroup(group, locale)}
{items.map((command, index) => { const globalIndex = startIndex + index return ( onHover(globalIndex)} onSelect={() => onSelect(command)} /> ) })}
) })}
) } function CommandPaletteFooter({ aiMode, aiAgentLabel = 'Claude Code', footerText, }: { aiMode: boolean aiAgentLabel?: string footerText: { aiMode: string navigate: string select: string send: string close: string } }) { return (
{aiMode ? footerText.aiMode.replace('{agent}', aiAgentLabel) : footerText.navigate} {aiMode ? footerText.send : footerText.select} {footerText.close}
) } export function CommandPalette({ open, ...props }: CommandPaletteProps) { if (!open) return null return } function OpenCommandPalette({ commands, entries = [], claudeCodeReady = true, aiAgentReady, aiAgentLabel = 'Claude Code', locale = 'en', onClose, }: Omit) { const [query, setQuery] = useState('') const [aiValue, setAiValue] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const aiInputRef = useRef(null) const listRef = useRef(null) const aiMode = aiValue.startsWith(' ') const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady const { groups, flatList } = usePaletteResults(commands, query) const t = createTranslator(locale) const footerText = { aiMode: t('command.aiMode', { agent: '{agent}' }), navigate: t('command.footerNavigate'), select: t('command.footerSelect'), send: t('command.footerSend'), close: t('command.footerClose'), } useLayoutEffect(() => { const target = aiMode ? aiInputRef.current : inputRef.current if (!target) return focusPaletteTarget(target) if (document.activeElement === target) return const focusRetry = window.requestAnimationFrame(() => { focusPaletteTarget(target) }) return () => window.cancelAnimationFrame(focusRetry) }, [aiMode]) useEffect(() => { if (aiMode || !listRef.current) return const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null selectedElement?.scrollIntoView({ block: 'nearest' }) }, [aiMode, selectedIndex]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() onClose() return } if (aiMode) return if (event.key === 'ArrowDown') { event.preventDefault() setSelectedIndex((current) => Math.min(current + 1, flatList.length - 1)) return } if (event.key === 'ArrowUp') { event.preventDefault() setSelectedIndex((current) => Math.max(current - 1, 0)) return } if (event.key === 'Enter') { event.preventDefault() const command = flatList[selectedIndex] if (!command) return rememberCommandOpener(command, inputRef.current) onClose() command.execute() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [aiMode, flatList, onClose, selectedIndex]) const handleQueryChange = (nextQuery: string) => { setSelectedIndex(0) if (nextQuery.startsWith(' ')) { setAiValue(nextQuery) setQuery('') return } setQuery(nextQuery) } const handleAiValueChange = (nextValue: string) => { setSelectedIndex(0) if (nextValue.startsWith(' ')) { setAiValue(nextValue) return } setAiValue('') setQuery(nextValue) } const handleSelectCommand = (command: CommandAction) => { rememberCommandOpener(command, inputRef.current) onClose() command.execute() } const handleSubmitAiPrompt = (text: string, references: NoteReference[]) => { if (!text.trim()) { onClose() return } if (!resolvedAiAgentReady) return queueAiPrompt(text, references) requestOpenAiChat() onClose() } return (
event.stopPropagation()} > {aiMode ? ( ) : ( <> )}
) } interface CommandRowProps { command: CommandAction selected: boolean onHover: () => void onSelect: () => void } function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) { return (
{command.label} {command.shortcut && ( {command.shortcut} )}
) }