import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { Search } from 'lucide-react'; import { rank } from './match'; import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems'; // Spotlight: type a few letters, go somewhere. // // The dock is full and cannot grow, which is what this is for — once navigation lives here, the dock is // free to be a short list of what you actually use rather than a complete one. // // ── Keyboard first, and the trigger ── // // `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner // telling us what the chord actually collides with on the machine he uses: // // ctrl-space focuses the address bar in Brave // shift-space types a space — see below, this was the real problem // alt-space taken by the window menu, and the owner rebound it rather than give up the chord // // **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key // combination inside a text field: it types a space, and people hold shift through one more often than // they think while typing capitals. Guarding it meant IGNORING the trigger whenever focus was in an input // — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first. // A launcher you have to prepare for is not a launcher. // // Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space // needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus // is usually in the chat composer, so a launcher that stands down there is a launcher that does not open // when you want it. `preventDefault` on the handler covers the only real risk the guard addressed. // // Nothing else opens it. There is deliberately no second chord — two triggers for one thing means two // things to collide with, and this one moved twice already. // // Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it. /* No cap, deliberately, and it used to be 8. Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to type it. A launcher you can only use for things you can already name is not doing the job. The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a cap becomes indistinguishable from a missing feature. */ /** Label with the matched characters picked out, so it is obvious WHY a result is here. */ const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => { if (!hits.length) return <>{label}; const set = new Set(hits); return ( <> {label.split('').map((char, i) => set.has(i) ? ( {char} ) : ( {char} ), )} ); }; const Row = ({ item, hits, active, onPick, }: { item: SpotlightItem; hits: number[]; active: boolean; onPick: () => void; }) => { const Icon = item.icon; // Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the // row is actually out of sight, so arrowing through the visible middle of the list does not jump it. const ref = useRef(null); useEffect(() => { if (active) ref.current?.scrollIntoView({ block: 'nearest' }); }, [active]); return ( ); }; export const Spotlight = () => { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [active, setActive] = useState(0); const inputRef = useRef(null); const navigate = useNavigate(); const items = useSpotlightItems(); const results = useMemo(() => rank(items, query), [items, query]); const close = useCallback(() => { setOpen(false); setQuery(''); setActive(0); }, []); const pick = useCallback( (item: SpotlightItem | undefined) => { if (!item) return; close(); navigate(item.to); }, [close, navigate], ); // The trigger. Bound to the window rather than to a container, because it has to work while focus is // anywhere — including inside a panel that has its own key handling. useEffect(() => { const onKey = (ev: KeyboardEvent) => { // `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and // nothing at all on some), while the physical key is `Space` everywhere. // // No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it // "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the // guard meant the launcher did not open from the place it is most often wanted. `preventDefault` // below covers the only thing the guard was protecting against — a layout that would otherwise type // a character. if (ev.altKey && ev.code === 'Space') { ev.preventDefault(); setOpen((wasOpen) => !wasOpen); return; } if (ev.key === 'Escape' && open) close(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, close]); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); // Reset the cursor whenever the result set changes, so it never points past the end. useEffect(() => setActive(0), [query]); if (!open) return null; return (
ev.stopPropagation()} >
setQuery(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'ArrowDown') { ev.preventDefault(); setActive((i) => Math.min(i + 1, results.length - 1)); } else if (ev.key === 'ArrowUp') { ev.preventDefault(); setActive((i) => Math.max(i - 1, 0)); } else if (ev.key === 'Enter') { ev.preventDefault(); pick(results[active]?.item); } }} placeholder="Go to…" className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
{results.length === 0 ? (

Nothing matches “{query}”.

) : ( results.map((result, i) => ( pick(result.item)} /> )) )}
); };