feat(web): add settings search command palette

This commit is contained in:
Quick
2026-06-12 12:47:55 -04:00
parent 4e521ed2e8
commit 9508449859
13 changed files with 1774 additions and 209 deletions
@@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginInstallation } from "@/api/types";
import { buildAdminCommandNavSections } from "@/lib/adminNavigation";
const mocks = vi.hoisted(() => ({
navigateToPluginRoute: vi.fn(),
}));
vi.mock("@/lib/buildPluginHref", () => ({
navigateToPluginRoute: (...args: unknown[]) => mocks.navigateToPluginRoute(...args),
}));
import { AdminSectionCommandDialog } from "./AdminSectionCommandDialog";
function renderDialog(sections = buildAdminCommandNavSections(undefined)) {
render(
<MemoryRouter initialEntries={["/admin"]}>
<AdminSectionCommandDialog sections={sections} />
<CurrentPath />
</MemoryRouter>,
);
}
function CurrentPath() {
const location = useLocation();
return <output aria-label="Current path">{`${location.pathname}${location.search}`}</output>;
}
async function openDialog() {
fireEvent.keyDown(window, { key: "k", metaKey: true });
const searchBox = await screen.findByRole("searchbox", { name: "Search admin sections" });
await waitFor(() => expect(searchBox).toHaveFocus());
return searchBox;
}
describe("AdminSectionCommandDialog", () => {
beforeEach(() => {
mocks.navigateToPluginRoute.mockReset();
});
it("does not render a visible search input before Cmd+K", () => {
renderDialog();
expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull();
});
it("opens and focuses admin search with Cmd+K", async () => {
renderDialog();
await openDialog();
expect(screen.getByRole("option", { name: /Dashboard/ })).toBeInTheDocument();
});
it("searches all admin section groups", async () => {
renderDialog();
const searchBox = await openDialog();
await userEvent.type(searchBox, "history import");
expect(screen.getByRole("option", { name: /History Import/ })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /Settings/ })).not.toBeInTheDocument();
});
it("searches individual admin setting labels from the dashboard dialog", async () => {
renderDialog();
const searchBox = await openDialog();
await userEvent.type(searchBox, "pool max open");
expect(screen.getByRole("option", { name: /Database/ })).toBeInTheDocument();
expect(screen.getByText("Pool Max Open")).toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: /Database/ }));
expect(screen.getByLabelText("Current path")).toHaveTextContent("/admin/settings?tab=database");
});
it("includes admin plugin app destinations", async () => {
const sections = buildAdminCommandNavSections([
{
id: 7,
plugin_id: "arrproxy",
enabled: true,
routes: [
{
id: "admin",
method: "GET",
path: "/",
access: "admin",
navigable: true,
navigation_label: "ArrProxy",
navigation_kind: "admin",
static_asset: true,
},
],
} as PluginInstallation,
]);
renderDialog(sections);
const searchBox = await openDialog();
await userEvent.type(searchBox, "arrproxy");
await userEvent.click(screen.getByRole("option", { name: /ArrProxy/ }));
expect(mocks.navigateToPluginRoute).toHaveBeenCalledWith("/api/v1/plugins/7/");
expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull();
});
it("closes after choosing an internal result", async () => {
renderDialog();
const searchBox = await openDialog();
await userEvent.type(searchBox, "logs");
await userEvent.click(screen.getByRole("option", { name: /Logs/ }));
expect(screen.getByLabelText("Current path")).toHaveTextContent("/admin/logs");
expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull();
});
it("closes with Escape", async () => {
renderDialog();
await openDialog();
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull(),
);
});
it("captures Cmd+K before document-level global search handlers", async () => {
const globalSearchShortcut = vi.fn();
document.addEventListener("keydown", globalSearchShortcut);
try {
renderDialog();
fireEvent.keyDown(document.body, { key: "k", metaKey: true });
await screen.findByRole("searchbox", { name: "Search admin sections" });
expect(globalSearchShortcut).not.toHaveBeenCalled();
} finally {
document.removeEventListener("keydown", globalSearchShortcut);
}
});
});
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { VisuallyHidden } from "radix-ui";
import { useNavigate } from "react-router";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import {
countSettingsSearchItems,
filterSettingsSearchEntries,
filterSettingsSearchGroups,
} from "@/components/settings/settingsSearch";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import type { AdminNavGroup, AdminNavItem } from "@/lib/adminNavigation";
import { cn } from "@/lib/utils";
interface AdminSectionCommandDialogProps {
sections: readonly AdminNavGroup[];
}
export function AdminSectionCommandDialog({ sections }: AdminSectionCommandDialogProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const filteredSections = useMemo(
() => filterSettingsSearchGroups(sections, query),
[query, sections],
);
const results = useMemo(
() => filteredSections.flatMap((section) => section.items),
[filteredSections],
);
const resultIndexByHref = useMemo(
() => new Map(results.map((item, index) => [item.href, index])),
[results],
);
const totalCount = countSettingsSearchItems(sections);
const resultCount = results.length;
const selectedResult = selectedIndex >= 0 ? results[selectedIndex] : undefined;
const focusSearch = useCallback(() => {
const focus = () => {
inputRef.current?.focus();
inputRef.current?.select();
};
if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(focus);
return;
}
window.setTimeout(focus, 0);
}, []);
const closeDialog = useCallback(() => {
setOpen(false);
setQuery("");
setSelectedIndex(0);
}, []);
const openDialog = useCallback(() => {
setOpen(true);
focusSearch();
}, [focusSearch]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
openDialog();
};
window.addEventListener("keydown", onKeyDown, { capture: true });
return () => window.removeEventListener("keydown", onKeyDown, { capture: true });
}, [openDialog]);
const pickResult = useCallback(
(item: AdminNavItem) => {
closeDialog();
if (item.external) {
void navigateToPluginRoute(item.href);
return;
}
navigate(item.href);
},
[closeDialog, navigate],
);
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) => (resultCount === 0 ? -1 : (current + 1) % resultCount));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) =>
resultCount === 0 ? -1 : current <= 0 ? resultCount - 1 : current - 1,
);
} else if (event.key === "Enter") {
if (!selectedResult) return;
event.preventDefault();
pickResult(selectedResult);
} else if (event.key === "Escape") {
event.preventDefault();
closeDialog();
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
openDialog();
return;
}
closeDialog();
}}
>
<DialogContent
className="top-[18%] max-h-[min(34rem,calc(100dvh-4rem))] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-xl"
showCloseButton={false}
>
<VisuallyHidden.Root>
<DialogTitle>Search admin sections</DialogTitle>
<DialogDescription>Search and open admin sections.</DialogDescription>
</VisuallyHidden.Root>
<div className="border-border flex h-12 items-center border-b px-4">
<Search className="text-muted-foreground mr-3 h-4 w-4 shrink-0" aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleInputKeyDown}
placeholder="Search admin sections..."
aria-label="Search admin sections"
aria-activedescendant={selectedResult ? resultId(selectedResult.href) : undefined}
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent text-sm outline-none"
autoComplete="off"
autoFocus
/>
<kbd className="bg-muted text-muted-foreground pointer-events-none ml-3 hidden rounded border px-1.5 py-0.5 text-[10px] font-medium select-none sm:inline-flex">
ESC
</kbd>
</div>
<div className="max-h-[min(25rem,58vh)] overflow-y-auto overscroll-contain p-2">
{filteredSections.length > 0 ? (
<div role="listbox" aria-label="Admin sections" className="space-y-3">
{filteredSections.map((section) => (
<div key={section.label}>
<div className="text-muted-foreground px-2 pb-1 text-xs font-medium">
{section.label}
</div>
<div className="space-y-1">
{section.items.map((item) => {
const index = resultIndexByHref.get(item.href) ?? -1;
return (
<AdminCommandResultRow
key={item.href}
item={item}
query={query}
selected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onPick={() => pickResult(item)}
/>
);
})}
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">
No matching admin sections
</p>
)}
</div>
<div className="text-muted-foreground border-border border-t px-4 py-2 text-xs">
{query.trim()
? `${resultCount} ${resultCount === 1 ? "match" : "matches"}`
: `${totalCount} admin sections`}
</div>
</DialogContent>
</Dialog>
);
}
function AdminCommandResultRow({
item,
query,
selected,
onMouseEnter,
onPick,
}: {
item: AdminNavItem;
query: string;
selected: boolean;
onMouseEnter: () => void;
onPick: () => void;
}) {
const Icon = item.icon;
const matchingSettings = filterSettingsSearchEntries(item.settings, query).slice(0, 3);
return (
<button
id={resultId(item.href)}
type="button"
role="option"
aria-selected={selected}
data-selected={selected || undefined}
onMouseEnter={onMouseEnter}
onClick={onPick}
className={cn(
"focus-visible:ring-ring/50 flex w-full items-start gap-3 rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/70",
)}
>
<span className="text-muted-foreground mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="text-foreground block text-sm font-medium">{item.label}</span>
{item.description ? (
<span className="text-muted-foreground mt-0.5 block text-xs leading-relaxed">
{item.description}
</span>
) : null}
{matchingSettings.length > 0 ? (
<span className="text-muted-foreground mt-1 block text-xs">
{matchingSettings.map((setting) => setting.label).join(", ")}
</span>
) : null}
</span>
</button>
);
}
function resultId(href: string) {
return `admin-command-${href.replace(/[^a-z0-9]+/gi, "-")}`;
}
+18 -106
View File
@@ -1,51 +1,24 @@
import { Link, useLocation } from "react-router";
import {
LayoutDashboard,
Radio,
Library,
LayoutPanelTop,
PanelsTopLeft,
Users,
MonitorSmartphone,
History,
Captions,
Download,
SlidersHorizontal,
Server,
Bot,
ArrowLeft,
Wrench,
KeyRound,
CalendarClock,
ScrollText,
Blocks,
Puzzle,
Send,
RefreshCw,
SkipForward,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import type { ReactNode } from "react";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { SiloBrand } from "@/components/SiloBrand";
import {
ADMIN_NAV_SECTIONS,
buildAdminPluginNavItems,
type AdminNavGroup,
type AdminNavItem,
} from "@/lib/adminNavigation";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins";
import { useAdminSessions } from "@/hooks/queries/admin/stats";
import { useBuildInfo } from "@/hooks/queries/admin/system";
import { pluginRouteHref } from "@/lib/pluginRouteHref";
interface SidebarItem {
label: string;
icon: LucideIcon;
href: string;
exact?: boolean;
interface SidebarItem extends AdminNavItem {
badge?: ReactNode;
// external=true means render as <a> (full page navigation) instead of
// react-router <Link>. Used for plugin routes mounted at /api/v1/plugins/...
external?: boolean;
}
interface SidebarSection {
interface SidebarSection extends Omit<AdminNavGroup, "items"> {
label: string;
items: SidebarItem[];
}
@@ -75,63 +48,14 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
buildDisplay = buildInfo.data.display;
}
// Grouped by admin intent: monitoring, curating the catalog, background
// processing the server runs on its own, people and their data, and the
// server installation itself.
const sections: SidebarSection[] = [
{
label: "Overview",
items: [
{ label: "Dashboard", icon: LayoutDashboard, href: "/admin", exact: true },
{
label: "Activity",
icon: Radio,
href: "/admin/activity",
badge:
sessionCount > 0 ? <span className="live-badge">{sessionCount} live</span> : undefined,
},
{ label: "Logs", icon: ScrollText, href: "/admin/logs" },
],
},
{
label: "Content",
items: [
{ label: "Libraries", icon: Library, href: "/admin/libraries" },
{ label: "Collections", icon: LayoutPanelTop, href: "/admin/collections" },
{ label: "Sections", icon: PanelsTopLeft, href: "/admin/sections" },
{ label: "Requests", icon: Send, href: "/admin/requests" },
],
},
{
label: "Automation",
items: [
{ label: "Autoscan", icon: RefreshCw, href: "/admin/autoscan" },
{ label: "Scheduled Tasks", icon: CalendarClock, href: "/admin/tasks" },
{ label: "Subtitles", icon: Captions, href: "/admin/subtitles" },
{ label: "Markers", icon: SkipForward, href: "/admin/marker-history" },
{ label: "Recommendations", icon: Bot, href: "/admin/recommendations" },
],
},
{
label: "Users",
items: [
{ label: "Users", icon: Users, href: "/admin/users" },
{ label: "Devices", icon: MonitorSmartphone, href: "/admin/devices" },
{ label: "Playback History", icon: History, href: "/admin/history" },
{ label: "History Import", icon: Download, href: "/admin/history-import" },
],
},
{
label: "System",
items: [
{ label: "Settings", icon: SlidersHorizontal, href: "/admin/settings" },
{ label: "Plugins", icon: Blocks, href: "/admin/plugins" },
{ label: "Nodes", icon: Server, href: "/admin/nodes" },
{ label: "API Keys", icon: KeyRound, href: "/admin/api-keys" },
{ label: "Maintenance", icon: Wrench, href: "/admin/maintenance" },
],
},
];
const activityBadge =
sessionCount > 0 ? <span className="live-badge">{sessionCount} live</span> : undefined;
const sections: SidebarSection[] = ADMIN_NAV_SECTIONS.map((section) => ({
...section,
items: section.items.map((item) =>
item.href === "/admin/activity" ? { ...item, badge: activityBadge } : item,
),
}));
// Use the admin installations endpoint, not /settings/plugins — the user
// settings endpoint filters to plugins that expose user settings / a user-
@@ -139,19 +63,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
// arrouter. The admin sidebar needs the full installation list to render
// its "Plugin Apps" group.
const { data: adminInstallations } = useAdminPluginInstallations();
const adminPluginItems: SidebarItem[] = [];
for (const inst of adminInstallations ?? []) {
if (!inst.enabled) continue;
for (const route of inst.routes ?? []) {
if (!route.navigable || route.navigation_kind !== "admin") continue;
adminPluginItems.push({
label: route.navigation_label || inst.plugin_id,
icon: Puzzle,
href: pluginRouteHref(inst.id, route.path),
external: true,
});
}
}
const adminPluginItems = buildAdminPluginNavItems(adminInstallations);
if (adminPluginItems.length > 0) {
sections.push({ label: "Plugin Apps", items: adminPluginItems });
}
@@ -0,0 +1,93 @@
import { useEffect, useId, useRef } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface SettingsSearchInputProps {
value: string;
onChange: (value: string) => void;
resultCount: number;
totalCount: number;
placeholder?: string;
itemLabel?: string;
emptyLabel?: string;
className?: string;
}
export function SettingsSearchInput({
value,
onChange,
resultCount,
totalCount,
placeholder = "Search settings",
itemLabel = "settings sections",
emptyLabel = "No matching settings",
className,
}: SettingsSearchInputProps) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const hasQuery = value.trim().length > 0;
const status = hasQuery
? resultCount === 0
? emptyLabel
: `${resultCount} ${resultCount === 1 ? "match" : "matches"}`
: `${totalCount} ${itemLabel}`;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
inputRef.current?.focus();
inputRef.current?.select();
};
window.addEventListener("keydown", onKeyDown, { capture: true });
document.addEventListener("keydown", onKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", onKeyDown, { capture: true });
document.removeEventListener("keydown", onKeyDown, { capture: true });
};
}, []);
return (
<div className={cn("w-full", className)}>
<label htmlFor={inputId} className="sr-only">
{placeholder}
</label>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref={inputRef}
id={inputId}
type="search"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-10 rounded-xl pr-10 pl-9"
autoComplete="off"
/>
{hasQuery ? (
<button
type="button"
aria-label="Clear settings search"
onClick={() => onChange("")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
</div>
<p className="text-muted-foreground mt-2 text-xs" aria-live="polite">
{status}
</p>
</div>
);
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { countSettingsSearchItems, filterSettingsSearchGroups } from "./settingsSearch";
const groups = [
{
label: "Server",
items: [
{
label: "General",
description: "Authentication and logging",
keywords: ["access token", "refresh token", "log level"],
settings: [{ label: "Quiet Subsystems" }],
},
{
label: "Database",
description: "Postgres and Redis",
keywords: ["connection url", "pool"],
settings: [{ label: "Pool Max Open" }],
},
],
},
{
label: "Playback",
items: [
{
label: "Subtitles",
description: "Skipping behavior, language, and style",
keywords: ["forced subtitles", "captions"],
},
],
},
];
describe("settingsSearch", () => {
it("returns all groups when the query is empty", () => {
const filtered = filterSettingsSearchGroups(groups, "");
expect(countSettingsSearchItems(filtered)).toBe(3);
expect(filtered.map((group) => group.label)).toEqual(["Server", "Playback"]);
});
it("matches item labels, descriptions, and keywords", () => {
expect(filterSettingsSearchGroups(groups, "redis")).toEqual([
{
label: "Server",
items: [groups[0]!.items[1]],
},
]);
expect(filterSettingsSearchGroups(groups, "access token")).toEqual([
{
label: "Server",
items: [groups[0]!.items[0]],
},
]);
expect(filterSettingsSearchGroups(groups, "forced")).toEqual([
{
label: "Playback",
items: [groups[1]!.items[0]],
},
]);
});
it("matches individual setting labels", () => {
expect(filterSettingsSearchGroups(groups, "quiet subsystems")).toEqual([
{
label: "Server",
items: [groups[0]!.items[0]],
},
]);
expect(filterSettingsSearchGroups(groups, "pool max open")).toEqual([
{
label: "Server",
items: [groups[0]!.items[1]],
},
]);
});
it("matches section labels by returning the full section", () => {
const filtered = filterSettingsSearchGroups(groups, "server");
expect(filtered).toEqual([groups[0]]);
expect(countSettingsSearchItems(filtered)).toBe(2);
});
it("does not match short tokens from the middle of a word", () => {
expect(filterSettingsSearchGroups(groups, "pin")).toEqual([]);
});
});
@@ -0,0 +1,99 @@
export interface SettingsSearchEntry {
label: string;
description?: string;
keywords?: readonly string[];
}
export interface SettingsSearchItem {
label: string;
description?: string;
keywords?: readonly string[];
settings?: readonly SettingsSearchEntry[];
}
export interface SettingsSearchGroup<T extends SettingsSearchItem> {
label: string;
items: readonly T[];
}
function normalizeSearchText(value: string) {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function searchTokens(query: string) {
const normalized = normalizeSearchText(query);
return normalized ? normalized.split(/\s+/) : [];
}
function itemSearchText<T extends SettingsSearchItem>(group: SettingsSearchGroup<T>, item: T) {
const settingText = item.settings?.flatMap((setting) => [
setting.label,
setting.description,
...(setting.keywords ?? []),
]);
return normalizeSearchText(
[group.label, item.label, item.description, ...(item.keywords ?? []), ...(settingText ?? [])]
.filter(Boolean)
.join(" "),
);
}
function entrySearchText(entry: SettingsSearchEntry) {
return normalizeSearchText([entry.label, entry.description, ...(entry.keywords ?? [])].join(" "));
}
function textMatchesTokens(text: string, tokens: string[]) {
const words = text.split(/\s+/).filter(Boolean);
return tokens.every((token) =>
words.some((word) => word.startsWith(token) || (token.length >= 4 && word.includes(token))),
);
}
export function filterSettingsSearchEntries(
entries: readonly SettingsSearchEntry[] | undefined,
query: string,
) {
const tokens = searchTokens(query);
if (!entries?.length || !tokens.length) {
return [];
}
return entries.filter((entry) => textMatchesTokens(entrySearchText(entry), tokens));
}
export function filterSettingsSearchGroups<T extends SettingsSearchItem>(
groups: readonly SettingsSearchGroup<T>[],
query: string,
): SettingsSearchGroup<T>[] {
const tokens = searchTokens(query);
if (!tokens.length) {
return groups.map((group) => ({ ...group, items: [...group.items] }));
}
return groups
.map((group) => {
const groupText = normalizeSearchText(group.label);
const groupMatches = textMatchesTokens(groupText, tokens);
const items = groupMatches
? [...group.items]
: group.items.filter((item) => textMatchesTokens(itemSearchText(group, item), tokens));
return { ...group, items };
})
.filter((group) => group.items.length > 0);
}
export function countSettingsSearchItems<T extends SettingsSearchItem>(
groups: readonly SettingsSearchGroup<T>[],
) {
return groups.reduce((count, group) => count + group.items.length, 0);
}