Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes Command palette search #121

Merged
merged 13 commits into from
Oct 31, 2024
315 changes: 174 additions & 141 deletions src/components/CmdKMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,7 @@ import { Snippet } from "fake-snippets-api/lib/db/schema"
import React from "react"
import { useQuery } from "react-query"

type SnippetType =
| "board"
| "package"
| "model"
| "footprint"
| "snippet"
| "search-result"
type SnippetType = "board" | "package" | "model" | "footprint" | "snippet"

interface Template {
name: string
Expand Down Expand Up @@ -49,6 +43,15 @@ interface CommandGroup {
items: CommandItemData[]
}

function fuzzySearch(str: string, query: string): boolean {
const pattern = query
.split("")
.map((char) => char.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join(".*")
const regex = new RegExp(pattern, "i")
return regex.test(str)
}

const CmdKMenu: React.FC = () => {
const [open, setOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState("")
Expand All @@ -57,18 +60,22 @@ const CmdKMenu: React.FC = () => {
const axios = useAxios()
const currentUser = useGlobalStore((s) => s.session?.github_username)

React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setOpen((prev) => !prev)
}
}

window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
// Search results query
const { data: searchResults, isLoading: isSearching } = useQuery(
["snippetSearch", searchQuery],
async () => {
if (!searchQuery) return []
const { data } = await axios.get("/snippets/search", {
params: { q: searchQuery },
})
return data.snippets
},
{
enabled: Boolean(searchQuery),
},
)

// Recent snippets query (only when not searching)
const { data: recentSnippets } = useQuery<Snippet[]>(
["userSnippets", currentUser],
async () => {
Expand All @@ -79,26 +86,22 @@ const CmdKMenu: React.FC = () => {
return response.data.snippets
},
{
enabled: !!currentUser,
enabled: !!currentUser && !searchQuery,
},
)

// Add search results query
const { data: searchResults, isLoading: isSearching } = useQuery(
["snippetSearch", searchQuery],
async () => {
if (!searchQuery) return []
const { data } = await axios.get("/snippets/search", {
params: { q: searchQuery },
})
return data.snippets
},
{
enabled: Boolean(searchQuery),
},
)
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setOpen((prev) => !prev)
}
}

window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])

// Templates and import options
const blankTemplates: Template[] = [
{ name: "Blank Circuit Board", type: "board" },
{ name: "Blank Circuit Module", type: "package" },
Expand All @@ -115,76 +118,106 @@ const CmdKMenu: React.FC = () => {
{ name: "JLCPCB Component", type: "package", special: true },
]

// Combine regular commands with search results
const commands: CommandGroup[] = [
...(searchResults && searchResults.length > 0
? [
{
group: "Search Results",
items: searchResults.map((snippet: any) => ({
label: snippet.name,
href: `/editor?snippet_id=${snippet.snippet_id}`,
type: "search-result" as const,
subtitle: `By ${snippet.owner_name}`,
description: snippet.description,
})),
},
]
: []),
{
group: "Recent Snippets",
items: (recentSnippets?.slice(0, 6) || []).map((snippet) => ({
label: snippet.unscoped_name,
href: `/editor?snippet_id=${snippet.snippet_id}`,
type: "snippet" as const,
subtitle: `Last edited: ${new Date(snippet.updated_at).toLocaleDateString()}`,
})),
},
{
group: "Start Blank Snippet",
items: blankTemplates.map((template) => ({
label: template.name,
href: template.disabled
? undefined
: `/editor?template=${template.name.toLowerCase().replace(/ /g, "-")}`,
type: template.type,
disabled: template.disabled,
})),
},
{
group: "Start from Template",
items: templates.map((template) => ({
label: template.name,
href: `/editor?template=${template.name.toLowerCase().replace(/ /g, "-")}`,
type: template.type,
})),
},
{
group: "Import",
items: importOptions.map((option) => ({
label: `Import ${option.name}`,
action: option.special
? () => {
setOpen(false)
setIsJLCPCBDialogOpen(true)
}
: () => {
setOpen(false)
toastNotImplemented(`${option.name} Import`)
},
type: option.type,
})),
},
]
const baseCommands = React.useMemo(() => {
const commands: CommandGroup[] = []

const filteredCommands = commands
.map((group) => ({
...group,
items: group.items.filter((command) =>
command.label.toLowerCase().includes(searchQuery.toLowerCase()),
),
}))
.filter((group) => group.items.length > 0)
// Only show search results if there's a search query
if (searchQuery && searchResults?.length) {
commands.push({
group: "Search Results",
items: searchResults.map((snippet: Snippet) => ({
label: snippet.name || snippet.unscoped_name,
href: `/editor?snippet_id=${snippet.snippet_id}`,
type: "snippet" as const,
description: snippet.description,
subtitle: `Last edited: ${new Date(snippet.updated_at).toLocaleDateString()}`,
})),
})
}

// Only show recent snippets if there's no search query
if (!searchQuery && recentSnippets?.length) {
commands.push({
group: "Recent Snippets",
items: recentSnippets.slice(0, 6).map((snippet) => ({
label: snippet.unscoped_name,
href: `/editor?snippet_id=${snippet.snippet_id}`,
type: "snippet" as const,
subtitle: `Last edited: ${new Date(snippet.updated_at).toLocaleDateString()}`,
})),
})
}

commands.push(
{
group: "Start Blank Snippet",
items: blankTemplates.map((template) => ({
label: template.name,
href: template.disabled
? undefined
: `/editor?template=${template.name.toLowerCase().replace(/ /g, "-")}`,
type: template.type,
disabled: template.disabled,
})),
},
{
group: "Start from Template",
items: templates.map((template) => ({
label: template.name,
href: `/editor?template=${template.name.toLowerCase().replace(/ /g, "-")}`,
type: template.type,
})),
},
{
group: "Import",
items: importOptions.map((option) => ({
label: `Import ${option.name}`,
action: option.special
? () => {
setOpen(false)
setIsJLCPCBDialogOpen(true)
}
: () => {
setOpen(false)
toastNotImplemented(`${option.name} Import`)
},
type: option.type,
})),
},
)

return commands
}, [
searchQuery,
searchResults,
recentSnippets,
blankTemplates,
templates,
importOptions,
])

const filteredCommands = React.useMemo(() => {
if (!searchQuery) return baseCommands

return baseCommands
.map((group) => ({
...group,
items: group.items.filter((command) => {
const searchString = [
command.label,
command.subtitle,
command.description,
command.type,
]
.filter(Boolean)
.join(" ")
.toLowerCase()

return fuzzySearch(searchString, searchQuery.toLowerCase())
}),
}))
.filter((group) => group.items.length > 0)
}, [baseCommands, searchQuery])

return (
<>
Expand All @@ -195,47 +228,47 @@ const CmdKMenu: React.FC = () => {
onValueChange={setSearchQuery}
/>
<CommandList>
{isSearching && (
<CommandGroup heading="Search">
<CommandItem disabled>Searching...</CommandItem>
{isSearching ? (
<CommandGroup heading="Searching...">
<CommandItem disabled>Loading results...</CommandItem>
</CommandGroup>
)}

{filteredCommands.length > 0 ? (
) : filteredCommands.length > 0 ? (
filteredCommands.map((group, groupIndex) => (
<CommandGroup key={groupIndex} heading={group.group}>
{group.items.map((command, itemIndex) => (
<CommandItem
key={itemIndex}
onSelect={() => {
if (command.action) {
command.action()
} else if (command.href && !command.disabled) {
window.location.href = command.href
setOpen(false)
}
}}
disabled={command.disabled}
className="flex items-center justify-between"
>
<div className="flex flex-col">
<span>{command.label}</span>
{command.subtitle && (
<span className="text-sm text-gray-500">
{command.subtitle}
</span>
)}
{command.description && (
<span className="text-sm text-gray-500">
{command.description}
</span>
)}
</div>
<span className="text-sm text-gray-500 ml-2">
{command.type}
</span>
</CommandItem>
))}
{group.items.map(
(command: CommandItemData, itemIndex: number) => (
<CommandItem
key={itemIndex}
onSelect={() => {
if (command.action) {
command.action()
} else if (command.href && !command.disabled) {
window.location.href = command.href
setOpen(false)
}
}}
disabled={command.disabled}
className="flex items-center justify-between"
>
<div className="flex flex-col">
<span>{command.label}</span>
{command.subtitle && (
<span className="text-sm text-gray-500">
{command.subtitle}
</span>
)}
{command.description && (
<span className="text-sm text-gray-500">
{command.description}
</span>
)}
</div>
<span className="text-sm text-gray-500 ml-2">
{command.type}
</span>
</CommandItem>
),
)}
</CommandGroup>
))
) : (
Expand Down
Loading