feat: fix rerenders on search input (#3784)

* chore: fix conflicts

* chore: update contents

* chore: fix review changes
This commit is contained in:
Adithya Krishna
2026-03-27 00:36:31 +05:30
committed by GitHub
parent 5aa0b4d5d4
commit bc4b7f5759
5 changed files with 584 additions and 354 deletions

View File

@@ -0,0 +1,165 @@
'use client'
import type { ComponentType } from 'react'
import { memo } from 'react'
import { Command } from 'cmdk'
import { Blimp } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { CommandItemProps } from '../utils'
import { COMMAND_ITEM_CLASSNAME } from '../utils'
export const MemoizedCommandItem = memo(
function CommandItem({
value,
onSelect,
icon: Icon,
bgColor,
showColoredIcon,
children,
}: CommandItemProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-icon)]'
)}
/>
</div>
<span className='truncate font-base text-[var(--text-body)]'>{children}</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.children === next.children
)
export const MemoizedWorkflowItem = memo(
function WorkflowItem({
value,
onSelect,
color,
name,
isCurrent,
}: {
value: string
onSelect: () => void
color: string
name: string
isCurrent?: boolean
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-sm border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate font-base text-[var(--text-body)]'>
{name}
{isCurrent && ' (current)'}
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.color === next.color &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent
)
export const MemoizedTaskItem = memo(
function TaskItem({
value,
onSelect,
name,
}: {
value: string
onSelect: () => void
name: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Blimp className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</div>
<span className='truncate font-base text-[var(--text-body)]'>{name}</span>
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name
)
export const MemoizedWorkspaceItem = memo(
function WorkspaceItem({
value,
onSelect,
name,
isCurrent,
}: {
value: string
onSelect: () => void
name: string
isCurrent?: boolean
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate font-base text-[var(--text-body)]'>
{name}
{isCurrent && ' (current)'}
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent
)
export const MemoizedPageItem = memo(
function PageItem({
value,
onSelect,
icon: Icon,
name,
shortcut,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</div>
<span className='truncate font-base text-[var(--text-body)]'>{name}</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 font-base text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut
)

View File

@@ -0,0 +1,241 @@
'use client'
import { memo } from 'react'
import { Command } from 'cmdk'
import type {
SearchBlockItem,
SearchDocItem,
SearchToolOperationItem,
} from '@/stores/modals/search/types'
import type { PageItem, TaskItem, WorkflowItem, WorkspaceItem } from '../utils'
import { GROUP_HEADING_CLASSNAME } from '../utils'
import {
MemoizedCommandItem,
MemoizedPageItem,
MemoizedTaskItem,
MemoizedWorkflowItem,
MemoizedWorkspaceItem,
} from './command-items'
export const BlocksGroup = memo(function BlocksGroup({
items,
onSelect,
}: {
items: SearchBlockItem[]
onSelect: (block: SearchBlockItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Blocks' className={GROUP_HEADING_CLASSNAME}>
{items.map((block) => (
<MemoizedCommandItem
key={block.id}
value={`${block.name} block-${block.id}`}
onSelect={() => onSelect(block)}
icon={block.icon}
bgColor={block.bgColor}
showColoredIcon
>
{block.name}
</MemoizedCommandItem>
))}
</Command.Group>
)
})
export const ToolsGroup = memo(function ToolsGroup({
items,
onSelect,
}: {
items: SearchBlockItem[]
onSelect: (tool: SearchBlockItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Tools' className={GROUP_HEADING_CLASSNAME}>
{items.map((tool) => (
<MemoizedCommandItem
key={tool.id}
value={`${tool.name} tool-${tool.id}`}
onSelect={() => onSelect(tool)}
icon={tool.icon}
bgColor={tool.bgColor}
showColoredIcon
>
{tool.name}
</MemoizedCommandItem>
))}
</Command.Group>
)
})
export const TriggersGroup = memo(function TriggersGroup({
items,
onSelect,
}: {
items: SearchBlockItem[]
onSelect: (trigger: SearchBlockItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Triggers' className={GROUP_HEADING_CLASSNAME}>
{items.map((trigger) => (
<MemoizedCommandItem
key={trigger.id}
value={`${trigger.name} trigger-${trigger.id}`}
onSelect={() => onSelect(trigger)}
icon={trigger.icon}
bgColor={trigger.bgColor}
showColoredIcon
>
{trigger.name}
</MemoizedCommandItem>
))}
</Command.Group>
)
})
export const ToolOpsGroup = memo(function ToolOpsGroup({
items,
onSelect,
}: {
items: SearchToolOperationItem[]
onSelect: (op: SearchToolOperationItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Tool Operations' className={GROUP_HEADING_CLASSNAME}>
{items.map((op) => (
<MemoizedCommandItem
key={op.id}
value={`${op.searchValue} operation-${op.id}`}
onSelect={() => onSelect(op)}
icon={op.icon}
bgColor={op.bgColor}
showColoredIcon
>
{op.name}
</MemoizedCommandItem>
))}
</Command.Group>
)
})
export const DocsGroup = memo(function DocsGroup({
items,
onSelect,
}: {
items: SearchDocItem[]
onSelect: (doc: SearchDocItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Docs' className={GROUP_HEADING_CLASSNAME}>
{items.map((doc) => (
<MemoizedCommandItem
key={doc.id}
value={`${doc.name} docs documentation doc-${doc.id}`}
onSelect={() => onSelect(doc)}
icon={doc.icon}
bgColor='#6B7280'
showColoredIcon
>
{doc.name}
</MemoizedCommandItem>
))}
</Command.Group>
)
})
export const WorkflowsGroup = memo(function WorkflowsGroup({
items,
onSelect,
}: {
items: WorkflowItem[]
onSelect: (workflow: WorkflowItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Workflows' className={GROUP_HEADING_CLASSNAME}>
{items.map((workflow) => (
<MemoizedWorkflowItem
key={workflow.id}
value={`${workflow.name} workflow-${workflow.id}`}
onSelect={() => onSelect(workflow)}
color={workflow.color}
name={workflow.name}
isCurrent={workflow.isCurrent}
/>
))}
</Command.Group>
)
})
export const TasksGroup = memo(function TasksGroup({
items,
onSelect,
}: {
items: TaskItem[]
onSelect: (task: TaskItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Tasks' className={GROUP_HEADING_CLASSNAME}>
{items.map((task) => (
<MemoizedTaskItem
key={task.id}
value={`${task.name} task-${task.id}`}
onSelect={() => onSelect(task)}
name={task.name}
/>
))}
</Command.Group>
)
})
export const WorkspacesGroup = memo(function WorkspacesGroup({
items,
onSelect,
}: {
items: WorkspaceItem[]
onSelect: (workspace: WorkspaceItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Workspaces' className={GROUP_HEADING_CLASSNAME}>
{items.map((workspace) => (
<MemoizedWorkspaceItem
key={workspace.id}
value={`${workspace.name} workspace-${workspace.id}`}
onSelect={() => onSelect(workspace)}
name={workspace.name}
isCurrent={workspace.isCurrent}
/>
))}
</Command.Group>
)
})
export const PagesGroup = memo(function PagesGroup({
items,
onSelect,
}: {
items: PageItem[]
onSelect: (page: PageItem) => void
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Pages' className={GROUP_HEADING_CLASSNAME}>
{items.map((page) => (
<MemoizedPageItem
key={page.id}
value={`${page.name} page-${page.id}`}
onSelect={() => onSelect(page)}
icon={page.icon}
name={page.name}
shortcut={page.shortcut}
/>
))}
</Command.Group>
)
})

View File

@@ -0,0 +1,2 @@
export { SearchModal } from './search-modal'
export type { PageItem, SearchModalProps, TaskItem, WorkflowItem, WorkspaceItem } from './utils'

View File

@@ -1,10 +1,10 @@
'use client'
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { Command } from 'cmdk'
import { useParams, useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Blimp, Library } from '@/components/emcn'
import { Library } from '@/components/emcn'
import { Calendar, Database, File, HelpCircle, Settings, Table } from '@/components/emcn/icons'
import { Search } from '@/components/emcn/icons/search'
import { cn } from '@/lib/core/utils/cn'
@@ -18,74 +18,21 @@ import type {
SearchDocItem,
SearchToolOperationItem,
} from '@/stores/modals/search/types'
import {
BlocksGroup,
DocsGroup,
PagesGroup,
TasksGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
WorkflowsGroup,
WorkspacesGroup,
} from './_components/search-groups'
import type { PageItem, SearchModalProps, TaskItem, WorkflowItem, WorkspaceItem } from './utils'
import { filterAndSort } from './utils'
function scoreMatch(value: string, search: string): number {
if (!search) return 1
const valueLower = value.toLowerCase()
const searchLower = search.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.9
if (valueLower.includes(searchLower)) return 0.7
const words = searchLower.split(/\s+/).filter(Boolean)
if (words.length > 1) {
if (words.every((w) => valueLower.includes(w))) return 0.5
}
return 0
}
function filterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
if (!search) return items
const scored: [T, number][] = []
for (const item of items) {
const s = scoreMatch(toValue(item), search)
if (s > 0) scored.push([item, s])
}
scored.sort((a, b) => b[1] - a[1])
return scored.map(([item]) => item)
}
interface TaskItem {
id: string
name: string
href: string
}
interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflows?: WorkflowItem[]
workspaces?: WorkspaceItem[]
tasks?: TaskItem[]
isOnWorkflowPage?: boolean
}
interface WorkflowItem {
id: string
name: string
href: string
color: string
isCurrent?: boolean
}
interface WorkspaceItem {
id: string
name: string
href: string
isCurrent?: boolean
}
interface PageItem {
id: string
name: string
icon: React.ComponentType<{ className?: string }>
href?: string
onClick?: () => void
shortcut?: string
hidden?: boolean
}
export type { SearchModalProps } from './utils'
export function SearchModal({
open,
@@ -103,6 +50,11 @@ export function SearchModal({
const { navigateToSettings } = useSettingsNavigation()
const { config: permissionConfig } = usePermissionConfig()
const routerRef = useRef(router)
routerRef.current = router
const onOpenChangeRef = useRef(onOpenChange)
onOpenChangeRef.current = onOpenChange
useEffect(() => {
setMounted(true)
}, [])
@@ -213,13 +165,13 @@ export function SearchModal({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onOpenChange(false)
onOpenChangeRef.current(false)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onOpenChange])
}, [open])
const handleBlockSelect = useCallback(
(block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => {
@@ -230,69 +182,78 @@ export function SearchModal({
detail: { type: block.type, enableTriggerMode },
})
)
onOpenChange(false)
onOpenChangeRef.current(false)
},
[onOpenChange]
[]
)
const handleToolOperationSelect = useCallback(
(op: SearchToolOperationItem) => {
const handleToolOperationSelect = useCallback((op: SearchToolOperationItem) => {
window.dispatchEvent(
new CustomEvent('add-block-from-toolbar', {
detail: { type: op.blockType, presetOperation: op.operationId },
})
)
onOpenChangeRef.current(false)
}, [])
const handleWorkflowSelect = useCallback((workflow: WorkflowItem) => {
if (!workflow.isCurrent && workflow.href) {
routerRef.current.push(workflow.href)
window.dispatchEvent(
new CustomEvent('add-block-from-toolbar', {
detail: { type: op.blockType, presetOperation: op.operationId },
})
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
)
onOpenChange(false)
},
[onOpenChange]
)
}
onOpenChangeRef.current(false)
}, [])
const handleWorkflowSelect = useCallback(
(workflow: WorkflowItem) => {
if (!workflow.isCurrent && workflow.href) {
router.push(workflow.href)
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
)
const handleWorkspaceSelect = useCallback((workspace: WorkspaceItem) => {
if (!workspace.isCurrent && workspace.href) {
routerRef.current.push(workspace.href)
}
onOpenChangeRef.current(false)
}, [])
const handleTaskSelect = useCallback((task: TaskItem) => {
routerRef.current.push(task.href)
onOpenChangeRef.current(false)
}, [])
const handlePageSelect = useCallback((page: PageItem) => {
if (page.onClick) {
page.onClick()
} else if (page.href) {
if (page.href.startsWith('http')) {
window.open(page.href, '_blank', 'noopener,noreferrer')
} else {
routerRef.current.push(page.href)
}
onOpenChange(false)
},
[router, onOpenChange]
}
onOpenChangeRef.current(false)
}, [])
const handleDocSelect = useCallback((doc: SearchDocItem) => {
window.open(doc.href, '_blank', 'noopener,noreferrer')
onOpenChangeRef.current(false)
}, [])
const handleBlockSelectAsBlock = useCallback(
(block: SearchBlockItem) => handleBlockSelect(block, 'block'),
[handleBlockSelect]
)
const handleWorkspaceSelect = useCallback(
(workspace: WorkspaceItem) => {
if (!workspace.isCurrent && workspace.href) {
router.push(workspace.href)
}
onOpenChange(false)
},
[router, onOpenChange]
const handleBlockSelectAsTool = useCallback(
(tool: SearchBlockItem) => handleBlockSelect(tool, 'tool'),
[handleBlockSelect]
)
const handlePageSelect = useCallback(
(page: PageItem) => {
if (page.onClick) {
page.onClick()
} else if (page.href) {
if (page.href.startsWith('http')) {
window.open(page.href, '_blank', 'noopener,noreferrer')
} else {
router.push(page.href)
}
}
onOpenChange(false)
},
[router, onOpenChange]
const handleBlockSelectAsTrigger = useCallback(
(trigger: SearchBlockItem) => handleBlockSelect(trigger, 'trigger'),
[handleBlockSelect]
)
const handleDocSelect = useCallback(
(doc: SearchDocItem) => {
window.open(doc.href, '_blank', 'noopener,noreferrer')
onOpenChange(false)
},
[onOpenChange]
)
const handleOverlayClick = useCallback(() => {
onOpenChangeRef.current(false)
}, [])
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
@@ -344,13 +305,12 @@ export function SearchModal({
return createPortal(
<>
{/* Overlay */}
<div
className={cn(
'fixed inset-0 z-40 transition-opacity duration-100',
open ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
onClick={() => onOpenChange(false)}
onClick={handleOverlayClick}
aria-hidden={!open}
/>
@@ -381,183 +341,15 @@ export function SearchModal({
No results found.
</Command.Empty>
{filteredBlocks.length > 0 && (
<Command.Group heading='Blocks' className={groupHeadingClassName}>
{filteredBlocks.map((block) => (
<MemoizedCommandItem
key={block.id}
value={`${block.name} block-${block.id}`}
onSelect={() => handleBlockSelect(block, 'block')}
icon={block.icon}
bgColor={block.bgColor}
showColoredIcon
>
{block.name}
</MemoizedCommandItem>
))}
</Command.Group>
)}
{filteredTools.length > 0 && (
<Command.Group heading='Tools' className={groupHeadingClassName}>
{filteredTools.map((tool) => (
<MemoizedCommandItem
key={tool.id}
value={`${tool.name} tool-${tool.id}`}
onSelect={() => handleBlockSelect(tool, 'tool')}
icon={tool.icon}
bgColor={tool.bgColor}
showColoredIcon
>
{tool.name}
</MemoizedCommandItem>
))}
</Command.Group>
)}
{filteredTriggers.length > 0 && (
<Command.Group heading='Triggers' className={groupHeadingClassName}>
{filteredTriggers.map((trigger) => (
<MemoizedCommandItem
key={trigger.id}
value={`${trigger.name} trigger-${trigger.id}`}
onSelect={() => handleBlockSelect(trigger, 'trigger')}
icon={trigger.icon}
bgColor={trigger.bgColor}
showColoredIcon
>
{trigger.name}
</MemoizedCommandItem>
))}
</Command.Group>
)}
{filteredWorkflows.length > 0 && open && (
<Command.Group heading='Workflows' className={groupHeadingClassName}>
{filteredWorkflows.map((workflow) => (
<Command.Item
key={workflow.id}
value={`${workflow.name} workflow-${workflow.id}`}
onSelect={() => handleWorkflowSelect(workflow)}
className='group flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-5)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:aria-selected:bg-[var(--surface-4)]'
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-sm border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate font-base text-[var(--text-body)]'>
{workflow.name}
{workflow.isCurrent && ' (current)'}
</span>
</Command.Item>
))}
</Command.Group>
)}
{filteredTasks.length > 0 && open && (
<Command.Group heading='Tasks' className={groupHeadingClassName}>
{filteredTasks.map((task) => (
<Command.Item
key={task.id}
value={`${task.name} task-${task.id}`}
onSelect={() => {
router.push(task.href)
onOpenChange(false)
}}
className='group flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-5)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:aria-selected:bg-[var(--surface-4)]'
>
<div className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Blimp className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</div>
<span className='truncate font-base text-[var(--text-body)]'>{task.name}</span>
</Command.Item>
))}
</Command.Group>
)}
{filteredToolOps.length > 0 && (
<Command.Group heading='Tool Operations' className={groupHeadingClassName}>
{filteredToolOps.map((op) => (
<MemoizedCommandItem
key={op.id}
value={`${op.searchValue} operation-${op.id}`}
onSelect={() => handleToolOperationSelect(op)}
icon={op.icon}
bgColor={op.bgColor}
showColoredIcon
>
{op.name}
</MemoizedCommandItem>
))}
</Command.Group>
)}
{filteredWorkspaces.length > 0 && open && (
<Command.Group heading='Workspaces' className={groupHeadingClassName}>
{filteredWorkspaces.map((workspace) => (
<Command.Item
key={workspace.id}
value={`${workspace.name} workspace-${workspace.id}`}
onSelect={() => handleWorkspaceSelect(workspace)}
className='group flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-5)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:aria-selected:bg-[var(--surface-4)]'
>
<span className='truncate font-base text-[var(--text-body)]'>
{workspace.name}
{workspace.isCurrent && ' (current)'}
</span>
</Command.Item>
))}
</Command.Group>
)}
{filteredDocs.length > 0 && (
<Command.Group heading='Docs' className={groupHeadingClassName}>
{filteredDocs.map((doc) => (
<MemoizedCommandItem
key={doc.id}
value={`${doc.name} docs documentation doc-${doc.id}`}
onSelect={() => handleDocSelect(doc)}
icon={doc.icon}
bgColor='#6B7280'
showColoredIcon
>
{doc.name}
</MemoizedCommandItem>
))}
</Command.Group>
)}
{filteredPages.length > 0 && open && (
<Command.Group heading='Pages' className={groupHeadingClassName}>
{filteredPages.map((page) => {
const Icon = page.icon
return (
<Command.Item
key={page.id}
value={`${page.name} page-${page.id}`}
onSelect={() => handlePageSelect(page)}
className='group flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-5)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:aria-selected:bg-[var(--surface-4)]'
>
<div className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</div>
<span className='truncate font-base text-[var(--text-body)]'>
{page.name}
</span>
{page.shortcut && (
<span className='ml-auto flex-shrink-0 font-base text-[var(--text-subtle)] text-small'>
{page.shortcut}
</span>
)}
</Command.Item>
)
})}
</Command.Group>
)}
<BlocksGroup items={filteredBlocks} onSelect={handleBlockSelectAsBlock} />
<ToolsGroup items={filteredTools} onSelect={handleBlockSelectAsTool} />
<TriggersGroup items={filteredTriggers} onSelect={handleBlockSelectAsTrigger} />
<WorkflowsGroup items={filteredWorkflows} onSelect={handleWorkflowSelect} />
<TasksGroup items={filteredTasks} onSelect={handleTaskSelect} />
<ToolOpsGroup items={filteredToolOps} onSelect={handleToolOperationSelect} />
<WorkspacesGroup items={filteredWorkspaces} onSelect={handleWorkspaceSelect} />
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} />
<PagesGroup items={filteredPages} onSelect={handlePageSelect} />
</Command.List>
</Command>
</div>
@@ -565,57 +357,3 @@ export function SearchModal({
document.body
)
}
const groupHeadingClassName =
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-0.5 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:font-base [&_[cmdk-group-heading]]:text-caption [&_[cmdk-group-heading]]:text-[var(--text-icon)]'
interface CommandItemProps {
value: string
onSelect: () => void
icon: React.ComponentType<{ className?: string }>
bgColor: string
showColoredIcon?: boolean
children: React.ReactNode
}
// onSelect is safe to exclude: cmdk stores it in a ref (useAsRef) internally,
// so the latest closure is always invoked regardless of whether React re-renders.
const MemoizedCommandItem = memo(
function CommandItem({
value,
onSelect,
icon: Icon,
bgColor,
showColoredIcon,
children,
}: CommandItemProps) {
return (
<Command.Item
value={value}
onSelect={onSelect}
className='group flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-5)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:aria-selected:bg-[var(--surface-4)]'
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-icon)]'
)}
/>
</div>
<span className='truncate font-base text-[var(--text-body)]'>{children}</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.children === next.children
)

View File

@@ -0,0 +1,84 @@
import type { ComponentType, ReactNode } from 'react'
export interface TaskItem {
id: string
name: string
href: string
}
export interface WorkflowItem {
id: string
name: string
href: string
color: string
isCurrent?: boolean
}
export interface WorkspaceItem {
id: string
name: string
href: string
isCurrent?: boolean
}
export interface PageItem {
id: string
name: string
icon: ComponentType<{ className?: string }>
href?: string
onClick?: () => void
shortcut?: string
hidden?: boolean
}
export interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflows?: WorkflowItem[]
workspaces?: WorkspaceItem[]
tasks?: TaskItem[]
isOnWorkflowPage?: boolean
}
export interface CommandItemProps {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
bgColor: string
showColoredIcon?: boolean
children: ReactNode
}
export const GROUP_HEADING_CLASSNAME =
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-0.5 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:font-base [&_[cmdk-group-heading]]:text-[12px] [&_[cmdk-group-heading]]:text-[var(--text-icon)]'
export const COMMAND_ITEM_CLASSNAME =
'group flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-5)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:aria-selected:bg-[var(--surface-4)]'
export function scoreMatch(value: string, search: string): number {
if (!search) return 1
const valueLower = value.toLowerCase()
const searchLower = search.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.9
if (valueLower.includes(searchLower)) return 0.7
const words = searchLower.split(/\s+/).filter(Boolean)
if (words.length > 1) {
if (words.every((w) => valueLower.includes(w))) return 0.5
}
return 0
}
export function filterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
if (!search) return items
const scored: [T, number][] = []
for (const item of items) {
const s = scoreMatch(toValue(item), search)
if (s > 0) scored.push([item, s])
}
scored.sort((a, b) => b[1] - a[1])
return scored.map(([item]) => item)
}