The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you actually use rather than a complete one — which is the other half of this and is not done yet. It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed, permission-filtered answer to "what can this account open here": the shell's screens, plus whatever installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited. Settings sections are added, because they are what people hunt for and the dock deliberately does not carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings screens to the shell to save five lines. Matching is subsequence, not substring, and lives in its own file so it can be tested with strings: "chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests cover the ORDER rather than the matching, because returning the right set is easy and returning it with the right thing first is the product. ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not cmd-k either — that belongs to the browser on some platforms and to text fields on others. Mounted beside the header and the dock rather than inside the content region, so it still gets you off a screen that is maximised, erroring or mid-navigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
6.2 KiB
TypeScript
190 lines
6.2 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`. `alt-space` is the more macOS-shaped chord and is already load-bearing for the owner
|
|
// elsewhere, so it is deliberately not taken. `cmd-k` is not used either: it belongs to the browser's own
|
|
// surface on some platforms and to text fields on others.
|
|
//
|
|
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
|
|
|
|
const MAX_RESULTS = 8;
|
|
|
|
/** 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;
|
|
return (
|
|
<button
|
|
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 rounded-lg px-3 py-2 text-left transition-colors ${
|
|
active ? 'bg-accent' : 'hover:bg-accent/50'
|
|
}`}
|
|
>
|
|
<span
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
|
style={item.color ? { backgroundColor: `${item.color}22`, color: item.color } : undefined}
|
|
>
|
|
{item.image ? (
|
|
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
|
|
) : Icon ? (
|
|
<Icon className="h-4 w-4" />
|
|
) : (
|
|
<Search className="h-4 w-4" />
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm text-foreground">
|
|
<Highlighted label={item.label} hits={hits} />
|
|
</span>
|
|
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-[10px] 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).slice(0, MAX_RESULTS), [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) => {
|
|
if (ev.ctrlKey && 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 (
|
|
<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,560px)] 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-4 w-4 shrink-0 text-muted-foreground" />
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(ev) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-[52vh] 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>
|
|
);
|
|
};
|