Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space → ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction — alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and would rather move the app than the window manager. ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches the handler and nothing in this repository can help — the fix would be in Brave. `!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the two conditions are disjoint. CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the value is in state/shortcuts.ts. The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now would rewrite three handlers for nothing visible. Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
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 ──
|
|
//
|
|
// `ctrl-space`, and ONLY that. It has now been ctrl-space → shift-space → alt-space → ctrl-space, and every
|
|
// move was the owner naming a real collision on the machine he uses:
|
|
//
|
|
// shift-space types a space — see below, this was the worst of them
|
|
// alt-space the GNOME window menu. He rebound the OS to keep it, disliked what he rebound it TO, and
|
|
// asked for the OS binding back — so the app moves rather than the desktop.
|
|
// ctrl-space chosen deliberately on the way back. It was ruled out on 2026-08-20 as "focuses the
|
|
// address bar in Brave"; that observation was made before anything preventDefault'ed it, and
|
|
// the owner would rather rebind a browser than his window manager.
|
|
//
|
|
// If the address bar still steals it, the key never reaches this handler and nothing here can help — the fix
|
|
// is in Brave, not in this file. Worth knowing WHICH of the two it is before changing anything.
|
|
//
|
|
// **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.
|
|
//
|
|
// Ctrl-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.
|
|
//
|
|
// ── Sizing ──
|
|
//
|
|
// Loosened 2026-08-22: the first pass reused the app's list metrics (7px tile, text-sm, py-2) and read as
|
|
// cramped, because this is not a list inside a panel — it is a modal you look at for two seconds with
|
|
// nothing else competing for the eye. So the search input leads at `text-base`, rows are `py-2.5` with a
|
|
// 9px tile, and the label is `text-[15px]` with the hint and path kept deliberately subordinate.
|
|
//
|
|
// The panel widened with them (560 → 640). Rows now carry a label, a hint and a route on one line, and the
|
|
// route is the first thing to be squeezed out — it is also the thing that disambiguates two rows with the
|
|
// same name.
|
|
//
|
|
// The results box grew 52vh → 60vh for the arithmetic reason: taller rows in the same box show fewer
|
|
// results, and the cap on how many are listed was removed for a reason.
|
|
|
|
/*
|
|
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) ? (
|
|
<span key={i} className="text-duck-teal">
|
|
{char}
|
|
</span>
|
|
) : (
|
|
<span key={i}>{char}</span>
|
|
),
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
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<HTMLButtonElement | null>(null);
|
|
useEffect(() => {
|
|
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
|
|
}, [active]);
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
|
|
// overlay on the blur handler before the navigation ever ran.
|
|
onMouseDown={(ev) => {
|
|
ev.preventDefault();
|
|
onPick();
|
|
}}
|
|
className={`flex w-full items-center gap-3.5 rounded-lg px-3.5 py-2.5 text-left transition-colors ${
|
|
active ? 'bg-accent' : 'hover:bg-accent/50'
|
|
}`}
|
|
>
|
|
{/*
|
|
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
|
|
these are the SAME things and a second visual language for them is what made this list read as
|
|
somebody else's menu:
|
|
|
|
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
|
|
invisible, which is why the fill is not decoration
|
|
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
|
|
mistake, and every plugin shipping its own art is this case
|
|
|
|
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
|
|
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
|
*/}
|
|
<span
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
|
style={{
|
|
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
|
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
|
color: item.color ? '#fff' : undefined,
|
|
}}
|
|
>
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt=""
|
|
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
|
|
/>
|
|
) : Icon ? (
|
|
<Icon className={`h-[18px] w-[18px] ${item.color ? '' : 'text-muted-foreground'}`} />
|
|
) : (
|
|
<Search className="h-[18px] w-[18px] text-muted-foreground" />
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-[15px] leading-snug text-foreground">
|
|
<Highlighted label={item.label} hits={hits} />
|
|
</span>
|
|
{item.hint ? (
|
|
<span className="block truncate text-[13px] leading-snug text-muted-foreground">{item.hint}</span>
|
|
) : null}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground/60">{item.to}</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export const Spotlight = () => {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [active, setActive] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const navigate = useNavigate();
|
|
const items = useSpotlightItems();
|
|
|
|
const results = useMemo(() => rank(items, query), [items, query]);
|
|
|
|
/*
|
|
The window handler reads these through refs.
|
|
|
|
It has to: the listener is registered once per open, and closing over `results` and `active` would pin
|
|
it to their values at registration — so arrowing down would read a stale list and Enter would open
|
|
whatever was first when the panel appeared. Adding them to the effect's deps instead would tear down and
|
|
re-register the listener on every keystroke, which is the same bug wearing a costume.
|
|
*/
|
|
const resultsRef = useRef(results);
|
|
resultsRef.current = results;
|
|
const activeRef = useRef(active);
|
|
activeRef.current = active;
|
|
|
|
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) => {
|
|
// `ctrlKey` and `code`, not `key`: a modifier+space produces a different `key` on several layouts (and
|
|
// nothing at all on some), while the physical key is `Space` everywhere.
|
|
//
|
|
// `!ev.shiftKey`, because ctrl+SHIFT+space is the theme browser. Without it one chord would open both,
|
|
// which is the bug that took a capture-phase listener to fix on the theme side.
|
|
//
|
|
// 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.ctrlKey && !ev.shiftKey && !ev.altKey && ev.code === 'Space') {
|
|
ev.preventDefault();
|
|
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
|
|
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
|
|
// that sometimes did nothing: focus stayed in the composer, so ArrowDown went to the textarea and
|
|
// the launcher never saw it. Blurring the outgoing element is one line and removes the race rather
|
|
// than narrowing it.
|
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
|
setOpen((wasOpen) => !wasOpen);
|
|
return;
|
|
}
|
|
if (!open) return;
|
|
if (ev.key === 'Escape') {
|
|
close();
|
|
return;
|
|
}
|
|
// Navigation is handled HERE rather than on the input, so it works no matter what has focus — a
|
|
// panel that grabbed it back, a blur that lost the race, an element with its own key handling. The
|
|
// input's own onKeyDown used to own this, which made every keystroke conditional on focus having
|
|
// landed where we expected.
|
|
if (ev.key === 'ArrowDown') {
|
|
ev.preventDefault();
|
|
setActive((i) => Math.min(i + 1, resultsRef.current.length - 1));
|
|
} else if (ev.key === 'ArrowUp') {
|
|
ev.preventDefault();
|
|
setActive((i) => Math.max(i - 1, 0));
|
|
} else if (ev.key === 'Enter') {
|
|
ev.preventDefault();
|
|
pick(resultsRef.current[activeRef.current]?.item);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [open, close, pick]);
|
|
|
|
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 (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[12vh] backdrop-blur-sm"
|
|
onMouseDown={close}
|
|
>
|
|
<div
|
|
className="w-[min(92vw,640px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
|
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
|
|
onMouseDown={(ev) => ev.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-4">
|
|
<Search className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(ev) => setQuery(ev.target.value)}
|
|
placeholder="Go to…"
|
|
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-[60vh] overflow-y-auto p-2">
|
|
{results.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
|
) : (
|
|
results.map((result, i) => (
|
|
<Row
|
|
key={result.item.id}
|
|
item={result.item}
|
|
hits={result.hits}
|
|
active={i === active}
|
|
onPick={() => pick(result.item)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|