reported as a plugin whose dock tile had square corners while every other one was rounded, and the expectation was right: the rounding IS dock infrastructure and has been there all along. it was simply never reaching the artwork. the box is `rounded-xl` and the image inside it is not clipped. a `badge` tile insets its glyph to h-7, so the corners are empty and nothing looked wrong for as long as every plugin used one. `bare` fills the box edge to edge — so the first plugin to ship a logo on an OPAQUE background drew its own square corners over a rounded box, and became the one tile in the row that had not been told the shape. `overflow-hidden`, in the dock and in spotlight, which had the identical bug a few pixels away. it fixes every bare plugin rather than this one, and it is a no-op for badge tiles, which is why it belongs on the box and not on the image. the plugins list drew the same artwork with no rounding at all while the grid beside it used `rounded-xl`. rounded to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
324 lines
15 KiB
TypeScript
324 lines
15 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';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
// 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 ──
|
|
//
|
|
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
|
|
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
|
|
//
|
|
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
|
|
// preventDefault would win. It does not — the key never reaches this handler.
|
|
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
|
|
// would rather move the app than the window manager.
|
|
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
|
|
//
|
|
// ── Shift-space overrides two things, on purpose ──
|
|
//
|
|
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
|
|
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
|
|
// and typing a space is what the unmodified spacebar is for.
|
|
//
|
|
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
|
|
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
|
|
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
|
|
// what made the launcher unopenable from the composer, where it is most wanted.
|
|
//
|
|
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
|
|
// is the argument for making them configurable, not for choosing better.
|
|
//
|
|
// **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.
|
|
//
|
|
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. 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
|
|
// `overflow-hidden` for the same reason as the dock: `bare` artwork fills the box, so without it
|
|
// the rounding is drawn and then painted over. The two have to agree — the same plugin appears in
|
|
// both, a few pixels apart.
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden 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 { t } = useTranslation();
|
|
|
|
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) => {
|
|
// `code`, not `key`: ctrl+space reports `key: ' '` on most layouts and something else on a few, while
|
|
// the physical key is `Space` everywhere.
|
|
//
|
|
// Every other modifier must be ABSENT. `ctrl+shift+space` and `ctrl+alt+space` are the theme
|
|
// browser, and one chord opening both is the bug that took a capture-phase listener to fix on the
|
|
// theme side.
|
|
//
|
|
// Was shift+space until 2026-08-25: typing fires shift+space constantly (any capitalized word
|
|
// followed by a space), so the launcher kept popping open mid-sentence. Ctrl+space doesn't happen in
|
|
// normal typing.
|
|
//
|
|
// 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.metaKey && 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={t('spotlight.placeholder')}
|
|
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">{t('spotlight.noResults', { 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>
|
|
);
|
|
};
|