mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat: fix rerenders on search input (#3784)
* chore: fix conflicts * chore: update contents * chore: fix review changes
This commit is contained in:
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SearchModal } from './search-modal'
|
||||
export type { PageItem, SearchModalProps, TaskItem, WorkflowItem, WorkspaceItem } from './utils'
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user