The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
import clsx from "clsx";
|
|
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Search } from "lucide-react";
|
|
import Modal from "./Modal";
|
|
import { closeTopOverlayThenRun } from "../../lib/history";
|
|
import { pageStep, pageTarget } from "../../lib/listPaging";
|
|
import { Pressable } from "./Pressable";
|
|
import { InnerInput } from "./Input";
|
|
import { ROVING_HIGHLIGHT } from "./AnchoredPopover";
|
|
import { fieldClass } from "./fieldStyles";
|
|
|
|
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
|
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" idea — reaching a channel three
|
|
// modules away should cost a keystroke and a word, not a rail, a page and a scroll.
|
|
//
|
|
// Domain-free on purpose: the items arrive as a prop, because `components/ui` may not import a
|
|
// module or an API slice. `AppCommandPalette` (one level up) is what knows about modules, channels
|
|
// and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape,
|
|
// focus restore and Back-to-close as every other dialog.
|
|
|
|
interface CommandItem {
|
|
/** Unique across the whole palette; also the React key. */
|
|
id: string;
|
|
label: string;
|
|
/** Right-aligned secondary text — usually the group, so a result stays legible once filtered. */
|
|
hint?: string;
|
|
icon?: ReactNode;
|
|
/** Extra text the filter matches but the row does not show (a channel handle, an alias). */
|
|
keywords?: string;
|
|
run: () => void;
|
|
}
|
|
|
|
export interface CommandGroup {
|
|
id: string;
|
|
label: string;
|
|
items: CommandItem[];
|
|
}
|
|
|
|
/** Case- and accent-insensitive, so "audio" finds "Audió" and "penzugy" finds "pénzügy" — this is a
|
|
* bilingual app and nobody types the accents when they are in a hurry. */
|
|
function fold(s: string): string {
|
|
return s
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/\p{Diacritic}/gu, "");
|
|
}
|
|
|
|
/** How well an item answers the query: `[worst word, sum of words]`, lower is better. */
|
|
type Rank = readonly [number, number];
|
|
|
|
const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1];
|
|
|
|
/** Every query word must appear somewhere in the item's text — word-wise rather than substring, so
|
|
* "thevr pod" finds "TheVR Podcast", the way you actually half-remember a name. `null` = no match.
|
|
*
|
|
* Each word scores 0 when the label STARTS with it, 1 when it is elsewhere in the label, 2 when only
|
|
* the hint, the hidden keywords or the group name carry it. An item ranks by its worst word first, so
|
|
* a row whose label holds every word beats one that needed its handle for any of them. Before this
|
|
* the list was pure group order: "theme" put the channel "The Meltzone Podcast" — matched through its
|
|
* handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel. */
|
|
function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank | null {
|
|
const label = fold(item.label);
|
|
const rest = fold(`${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
|
|
let worst = 0;
|
|
let sum = 0;
|
|
for (const w of words) {
|
|
const score = label.startsWith(w) ? 0 : label.includes(w) ? 1 : rest.includes(w) ? 2 : -1;
|
|
if (score < 0) return null;
|
|
worst = Math.max(worst, score);
|
|
sum += score;
|
|
}
|
|
return [worst, sum];
|
|
}
|
|
|
|
/** The result count reaches a screen reader only once the typing pauses: a polite region rewritten on
|
|
* every keystroke queues one announcement per letter. */
|
|
const ANNOUNCE_DELAY_MS = 400;
|
|
|
|
export default function CommandPalette({
|
|
open,
|
|
onClose,
|
|
groups,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
groups: CommandGroup[];
|
|
}) {
|
|
// Closed means UNMOUNTED, not merely hidden, and that one line replaces the effect that used to
|
|
// blank the query and the picked row on every open: fresh state is what mounting already means.
|
|
// It also stops the ranking memo below — which folds and scores every channel, playlist and saved
|
|
// view — from re-running on every parent render while the palette is shut.
|
|
if (!open) return null;
|
|
return <CommandPaletteBody onClose={onClose} groups={groups} />;
|
|
}
|
|
|
|
function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups: CommandGroup[] }) {
|
|
const { t } = useTranslation();
|
|
const listId = useId();
|
|
const groupId = useId();
|
|
const [query, setQuery] = useState("");
|
|
// The row the arrows or the pointer picked, by id; `null` follows the best match. An id rather than
|
|
// an index because the lists arrive asynchronously (channels, playlists load on open), and an index
|
|
// would quietly point at a different row once they land.
|
|
const [picked, setPicked] = useState<string | null>(null);
|
|
// Ref on the highlighted row rather than a selector lookup: item ids come from domain data
|
|
// (playlist names, channel handles) and would need escaping to be safe in a query string.
|
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every
|
|
// time — and the rows are ranked inside each group. The best row overall starts highlighted, so
|
|
// Enter takes the best match even when it sits in a later group.
|
|
const { visible, bestId } = useMemo(() => {
|
|
const words = fold(query.trim()).split(/\s+/).filter(Boolean);
|
|
const out: CommandGroup[] = [];
|
|
let best: { id: string; rank: Rank } | null = null;
|
|
for (const g of groups) {
|
|
const ranked: { item: CommandItem; rank: Rank }[] = [];
|
|
for (const item of g.items) {
|
|
const rank = rankItem(item, g.label, words);
|
|
if (rank) ranked.push({ item, rank });
|
|
}
|
|
if (ranked.length === 0) continue;
|
|
// `sort` is stable, so equal ranks keep the group's own order (modules in nav-rail order).
|
|
ranked.sort((a, b) => byRank(a.rank, b.rank));
|
|
const top = ranked[0]!;
|
|
if (!best || byRank(top.rank, best.rank) < 0) best = { id: top.item.id, rank: top.rank };
|
|
out.push({ ...g, items: ranked.map((r) => r.item) });
|
|
}
|
|
return { visible: out, bestId: best?.id };
|
|
}, [groups, query]);
|
|
|
|
// One flat list behind the grouped rendering — arrow keys walk results, not sections.
|
|
const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]);
|
|
const activeId = picked !== null && flat.some((i) => i.id === picked) ? picked : bestId;
|
|
const activeIndex = flat.findIndex((i) => i.id === activeId);
|
|
|
|
// Keep the highlighted row in view when the arrows walk past the fold.
|
|
useEffect(() => {
|
|
activeRef.current?.scrollIntoView({ block: "nearest" });
|
|
}, [activeId]);
|
|
|
|
const summary = !query.trim()
|
|
? ""
|
|
: flat.length === 0
|
|
? t("palette.empty")
|
|
: t("palette.results", { count: flat.length });
|
|
const [announced, setAnnounced] = useState("");
|
|
useEffect(() => {
|
|
if (!summary) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- the announcement is DEBOUNCED on purpose (ANNOUNCE_DELAY_MS above): a polite region rewritten per keystroke queues one utterance per letter. A timer's result cannot be derived in render, and the empty branch is the same timer's cancel.
|
|
setAnnounced("");
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => setAnnounced(summary), ANNOUNCE_DELAY_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [summary]);
|
|
|
|
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
|
|
// navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
|
|
// a command that navigates closes whatever is underneath itself, one that does not (a theme) must
|
|
// leave it standing. See closeTopOverlayThenRun.
|
|
const choose = (item: CommandItem) => closeTopOverlayThenRun(onClose, item.run);
|
|
|
|
// These live on the input rather than in the shared keymap: they are the field's own editing keys
|
|
// (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
|
|
const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
if (flat.length === 0) return;
|
|
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
const from = Math.max(activeIndex, 0);
|
|
setPicked(flat[(from + step + flat.length) % flat.length]!.id);
|
|
} else if (e.key === "PageDown" || e.key === "PageUp") {
|
|
// A page at a time, stopping at the ends (lib/listPaging). The list is 50dvh and group headings
|
|
// eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends).
|
|
e.preventDefault();
|
|
const list = listRef.current;
|
|
const step = pageStep(list, list?.querySelectorAll('[role="option"]') ?? []);
|
|
const to = pageTarget(
|
|
Math.max(activeIndex, 0),
|
|
e.key === "PageDown" ? step : -step,
|
|
flat.length,
|
|
);
|
|
if (to !== null) setPicked(flat[to]!.id);
|
|
} else if (e.key === "Enter") {
|
|
const item = flat[activeIndex];
|
|
if (item) {
|
|
e.preventDefault();
|
|
choose(item);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
initialFocus="field"
|
|
title={t("palette.title")}
|
|
onClose={onClose}
|
|
maxWidth="max-w-xl"
|
|
placement="top"
|
|
>
|
|
<div className={clsx(fieldClass({ size: "lg" }), "flex items-center gap-2")}>
|
|
<Search className="w-4 h-4 shrink-0 text-muted" />
|
|
<InnerInput
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setPicked(null);
|
|
}}
|
|
onKeyDown={onInputKey}
|
|
placeholder={t("palette.placeholder")}
|
|
aria-label={t("palette.placeholder")}
|
|
// A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in
|
|
// the field, so a screen reader has to be told which row is current. `aria-expanded`
|
|
// follows the results: with nothing to choose from there is no popup to be in.
|
|
role="combobox"
|
|
aria-expanded={flat.length > 0}
|
|
aria-controls={listId}
|
|
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div role="status" aria-live="polite" className="sr-only">
|
|
{announced}
|
|
</div>
|
|
|
|
{/* Stays mounted (hidden) while empty, so the input's aria-controls never names a missing
|
|
element; the "nothing matches" line sits OUTSIDE it, since a listbox may hold only options
|
|
and groups. */}
|
|
<div
|
|
ref={listRef}
|
|
id={listId}
|
|
role="listbox"
|
|
aria-label={t("palette.title")}
|
|
hidden={flat.length === 0}
|
|
className="mt-3 max-h-[50dvh] overflow-y-auto"
|
|
>
|
|
{visible.map((g) => (
|
|
<div
|
|
key={g.id}
|
|
role="group"
|
|
aria-labelledby={`${groupId}-${g.id}`}
|
|
className="mb-2 last:mb-0"
|
|
>
|
|
<div
|
|
id={`${groupId}-${g.id}`}
|
|
role="presentation"
|
|
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
|
|
>
|
|
{g.label}
|
|
</div>
|
|
{g.items.map((item) => (
|
|
<Pressable
|
|
key={item.id}
|
|
id={`${listId}-${item.id}`}
|
|
ref={item.id === activeId ? activeRef : undefined}
|
|
data-cmd-id={item.id}
|
|
role="option"
|
|
aria-selected={item.id === activeId}
|
|
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
|
// these rows. Leaving them tabbable put DOM focus on a row while
|
|
// aria-activedescendant named a different one — two competing "current" options —
|
|
// and made Tab walk every result.
|
|
tabIndex={-1}
|
|
onClick={() => choose(item)}
|
|
onMouseMove={(e) => {
|
|
// Only a pointer that actually MOVED may take the selection. After the arrow keys
|
|
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
|
|
// cursor position to refresh hover — which handed the highlight straight back to
|
|
// whatever row slid under a resting cursor. The browser
|
|
// reports zero movement for exactly that synthetic event, so ask it directly:
|
|
// remembering the last position instead could not judge the FIRST
|
|
// move of a session and went stale across a close.
|
|
if (e.movementX === 0 && e.movementY === 0) return;
|
|
setPicked(item.id);
|
|
}}
|
|
// The menus' roving highlight: a `bg-card` row was ~1.07:1 on the palette's glass.
|
|
data-active={item.id === activeId ? "true" : undefined}
|
|
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-muted hover:text-fg ${ROVING_HIGHLIGHT}`}
|
|
>
|
|
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
|
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
|
</Pressable>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{flat.length === 0 && (
|
|
<p className="mt-3 px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|