diff --git a/frontend/src/components/command-menu.tsx b/frontend/src/components/command-menu.tsx index 7596b8307..081cade24 100644 --- a/frontend/src/components/command-menu.tsx +++ b/frontend/src/components/command-menu.tsx @@ -23,6 +23,34 @@ import { Button } from "./ui/button"; import { Badge } from "./ui/badge"; import Link from "next/link"; +function search(scripts: Script[], query: string): Script[] { + const queryLower = query.toLowerCase().trim(); + const searchWords = queryLower.split(/\s+/).filter(Boolean); + + return scripts + .map(script => { + const nameLower = script.name.toLowerCase(); + const descriptionLower = (script.description || "").toLowerCase(); + + let score = 0; + + for (const word of searchWords) { + if (nameLower.includes(word)) { + score += 10; + } + if (descriptionLower.includes(word)) { + score += 5; + } + } + + return { script, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(({ script }) => script); +} + export function formattedBadge(type: string) { switch (type) { case "vm": @@ -51,9 +79,11 @@ function getRandomScript(categories: Category[], previouslySelected: Set } function CommandMenu() { + const [query, setQuery] = React.useState(""); const [open, setOpen] = React.useState(false); const [links, setLinks] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); + const [results, setResults] = React.useState([]); const [selectedScripts, setSelectedScripts] = React.useState>(new Set()); const router = useRouter(); @@ -70,6 +100,27 @@ function CommandMenu() { }); }; + React.useEffect(() => { + if (query.trim() === "") { + fetchSortedCategories(); + } + else { + const scriptMap = new Map(); + + for (const category of links) { + for (const script of category.scripts || []) { + if (!scriptMap.has(script.slug)) { + scriptMap.set(script.slug, script); + } + } + } + + const uniqueScripts = Array.from(scriptMap.values()); + const filteredResults = search(uniqueScripts, query); + setResults(filteredResults); + } + }, [query]); + React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { @@ -197,20 +248,20 @@ function CommandMenu() { { - const searchLower = search.toLowerCase().trim(); - if (!searchLower) - return 1; - const valueLower = value.toLowerCase(); - const searchWords = searchLower.split(/\s+/).filter(Boolean); - // All search words must appear somewhere in the value (name + description) - const allWordsMatch = searchWords.every((word: string) => valueLower.includes(word)); - return allWordsMatch ? 1 : 0; + onOpenChange={(open) => { + setOpen(open); + if (open) { + setQuery(""); + setResults([]); + } }} > Search scripts - + {isLoading ? ( @@ -233,9 +284,10 @@ function CommandMenu() { )} - {Object.entries(uniqueScriptsByCategory).map(([categoryName, scripts]) => ( - - {scripts.map(script => ( + + {results.length > 0 ? ( + + {results.map(script => ( ))} - ))} + ) : ( // When no search results, show all scripts grouped by category + Object.entries(uniqueScriptsByCategory).map(([categoryName, scripts]) => ( + + {scripts.map(script => ( + { + setOpen(false); + router.push(`/scripts?id=${script.slug}`); + }} + tabIndex={0} + aria-label={`Open script ${script.name}`} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + setOpen(false); + router.push(`/scripts?id=${script.slug}`); + } + }} + > +
setOpen(false)}> + ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)} + unoptimized + width={16} + height={16} + alt="" + className="h-5 w-5" + /> + {script.name} + {formattedBadge(script.type)} +
+
+ ))} +
+ )) + )}