import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useActionRegistry, useHotkeyStore } from '../actions/ActionContext'; import { formatHotkeyForDisplay, eventToHotkey, normalizeHotkey, hotkeyToString, } from '../actions/hotkey-utils'; import { getRendererPlatform } from '../lib/platform'; import { PlusIcon, XIcon, SearchIcon } from '../icons'; import type { RegisteredAction, Hotkey } from '../actions/types'; /** Parse editingKey into action ID and slot info. */ function parseEditingKey(key: string | null): { actionId: string | null; slot: number | 'new' | null; } { if (!key) return { actionId: null, slot: null }; const colonIdx = key.lastIndexOf(':'); if (colonIdx < 0) return { actionId: null, slot: null }; const actionId = key.slice(0, colonIdx); const slotRaw = key.slice(colonIdx + 1); const slot = slotRaw === 'new' ? 'new' : parseInt(slotRaw, 10); return { actionId, slot }; } export function HotkeysPane() { const registry = useActionRegistry(); const hotkeyStore = useHotkeyStore(); const isMac = getRendererPlatform() === 'darwin'; const [editingKey, setEditingKey] = useState(null); const [captured, setCaptured] = useState(null); const [filter, setFilter] = useState(''); // Refs so the persistent keydown listener always sees latest state const editingKeyRef = useRef(editingKey); editingKeyRef.current = editingKey; const capturedRef = useRef(captured); capturedRef.current = captured; // Force re-render when overrides change so badges and conflicts update const [version, setVersion] = useState(0); useEffect(() => { return hotkeyStore.onChange(() => setVersion((v) => v + 1)); }, [hotkeyStore]); // Reset captured combo whenever the user switches to a different editing target useEffect(() => { setCaptured(null); }, [editingKey]); const allActions = useMemo(() => { const actions = registry.getAll(); // Sort by source then name for stable ordering actions.sort((a, b) => { if (a.source !== b.source) return a.source.localeCompare(b.source); return a.name.localeCompare(b.name); }); return actions; }, [registry]); const filteredActions = useMemo(() => { if (!filter.trim()) return allActions; const q = filter.toLowerCase(); return allActions.filter( (a) => a.name.toLowerCase().includes(q) || (a.description ?? '').toLowerCase().includes(q), ); }, [allActions, filter]); /** Whether the currently captured hotkey conflicts with another action's binding. */ const isCapturedConflicting = useMemo(() => { if (!captured || !editingKey) return false; const { actionId: editingActionId } = parseEditingKey(editingKey); const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); for (const action of allActions) { if (action.id === editingActionId) continue; // same-action duplicates are handled separately for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { if (hotkeyToString(normalizeHotkey(hk, isMac)) === capturedStr) { return true; } } } return false; }, [captured, editingKey, allActions, hotkeyStore, isMac, version]); /** Live conflicts including the currently captured (but not yet confirmed) hotkey. */ const liveConflicts = useMemo(() => { const base = hotkeyStore.getConflicts(registry, isMac); if (!captured || !editingKey) return base; const { actionId: editingActionId } = parseEditingKey(editingKey); const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); const result = new Map(base); for (const action of allActions) { if (action.id === editingActionId) continue; for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { const hkStr = hotkeyToString(normalizeHotkey(hk, isMac)); if (hkStr === capturedStr) { const existing = result.get(capturedStr) ?? []; if (!existing.includes(action.id)) { result.set(capturedStr, [...existing, action.id]); } } } } return result; }, [hotkeyStore, registry, isMac, captured, editingKey, allActions, version]); const finishEdit = useCallback( (actionId: string, nextHotkeys: Hotkey[]) => { hotkeyStore.setOverride(actionId, nextHotkeys); setEditingKey(null); setCaptured(null); }, [hotkeyStore], ); // One persistent document-level keydown listener. // Uses refs to avoid effect re-runs on every state change. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const currentEditing = editingKeyRef.current; if (!currentEditing) return; e.preventDefault(); e.stopPropagation(); const { actionId, slot } = parseEditingKey(currentEditing); if (!actionId || slot === null) return; const currentCaptured = capturedRef.current; const action = registry.get(actionId); if (!action) return; const base = hotkeyStore.getEffectiveHotkeys(action); if (e.key === 'Escape') { setEditingKey(null); setCaptured(null); return; } if (e.key === 'Enter') { const next = [...base]; if (currentCaptured) { if (slot === 'new') { next.push(currentCaptured); } else if (typeof slot === 'number') { next[slot] = currentCaptured; } } // Deduplicate within this action const seen = new Set(); const deduped = next.filter((hk) => { const key = hotkeyToString(normalizeHotkey(hk, isMac)); if (seen.has(key)) return false; seen.add(key); return true; }); finishEdit(actionId, deduped); return; } if (e.key === 'Delete' || e.key === 'Backspace') { const next = [...base]; if (typeof slot === 'number') { next.splice(slot, 1); } finishEdit(actionId, next); return; } // Skip bare modifier keys if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return; setCaptured(eventToHotkey(e)); }; document.addEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true); }, [registry, hotkeyStore, finishEdit]); const removeAt = (action: RegisteredAction, index: number) => { const next = [...hotkeyStore.getEffectiveHotkeys(action)]; next.splice(index, 1); hotkeyStore.setOverride(action.id, next); }; return (
{/* Conflict banner */} {liveConflicts.size > 0 ? (

Conflicting hotkeys

    {Array.from(liveConflicts.entries()).map(([key, actionIds]) => (
  • {key}: {actionIds.join(', ')}
  • ))}
) : null} {/* Filter bar */}
setFilter(e.target.value)} placeholder="Filter by name or description…" className="border-border bg-foreground/5 text-foreground placeholder:text-foreground/30 focus:border-accent focus:ring-accent w-full rounded-md border py-1.5 pr-3 pl-8 text-sm outline-none focus:ring-1" />
{/* Table — scrollable body */}
{filteredActions.map((action: RegisteredAction) => { const effective = hotkeyStore.getEffectiveHotkeys(action); const { actionId: editingActionId, slot } = parseEditingKey(editingKey); const isEditingAction = editingActionId === action.id; return (
{action.name} {action.description ? (

{action.description}

) : null}
{effective.map((hk, index) => { const isEditing = isEditingAction && slot === index; return ( {isEditing ? ( {captured ? formatHotkeyForDisplay(captured, isMac) : 'Press keys…'} ) : ( <> )} ); })}
{isEditingAction && slot === 'new' ? ( {captured ? formatHotkeyForDisplay(captured, isMac) : 'Press keys…'} ) : null}
); })} {filteredActions.length === 0 ? (
No commands match your filter
) : null}
); }