From 6494f614b4013454ac11499e9804792141f7def8 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 27 Jan 2026 23:42:43 -0800 Subject: [PATCH] improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs --- apps/docs/components/icons.tsx | 385 ++++--- .../[workspaceId]/w/[workflowId]/workflow.tsx | 63 +- .../components/search-modal/search-modal.tsx | 1000 +++++++---------- .../components/search-modal/search-utils.ts | 241 ---- .../w/components/sidebar/sidebar.tsx | 7 +- apps/sim/components/icons.tsx | 385 ++++--- apps/sim/stores/modals/search/store.ts | 143 ++- apps/sim/stores/modals/search/types.ts | 65 +- 8 files changed, 1063 insertions(+), 1226 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 9c901860a..f9b47f43c 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react' +import { useId } from 'react' export function SearchIcon(props: SVGProps) { return ( @@ -737,6 +738,9 @@ export function GmailIcon(props: SVGProps) { } export function GrafanaIcon(props: SVGProps) { + const id = useId() + const gradientId = `grafana_gradient_${id}` + return ( ) { fill='none' > ) { } export function SupabaseIcon(props: SVGProps) { + const id = useId() + const gradient0 = `supabase_paint0_${id}` + const gradient1 = `supabase_paint1_${id}` + return ( ) { > ) { /> ) { ) { } export function MistralIcon(props: SVGProps) { + const id = useId() + const clipId = `mistral_clip_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMidYMid meet' > - + ) { /> - + @@ -2126,6 +2137,9 @@ export function MicrosoftIcon(props: SVGProps) { } export function MicrosoftTeamsIcon(props: SVGProps) { + const id = useId() + const gradientId = `msteams_gradient_${id}` + return ( ) { d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z' /> ) { ) { } export function OutlookIcon(props: SVGProps) { + const id = useId() + const gradient1 = `outlook_gradient1_${id}` + const gradient2 = `outlook_gradient2_${id}` + return ( ) { ) { ) { d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z' /> ) { ) { } export function MicrosoftExcelIcon(props: SVGProps) { + const id = useId() + const gradientId = `excel_gradient_${id}` + return ( ) { d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z' /> ) { ) => ( ) -export const AzureIcon = (props: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - -) +export function AzureIcon(props: SVGProps) { + const id = useId() + const gradient0 = `azure_paint0_${id}` + const gradient1 = `azure_paint1_${id}` + const gradient2 = `azure_paint2_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} export const GroqIcon = (props: SVGProps) => ( ) => ( ) -export const GeminiIcon = (props: SVGProps) => ( - - Gemini - - - - - - - - - -) +export function GeminiIcon(props: SVGProps) { + const id = useId() + const gradientId = `gemini_gradient_${id}` + + return ( + + Gemini + + + + + + + + + + ) +} export const VertexIcon = (props: SVGProps) => ( ) { } export function QdrantIcon(props: SVGProps) { + const id = useId() + const gradientId = `qdrant_gradient_${id}` + const clipPathId = `qdrant_clippath_${id}` + return ( - + ) { /> ) { - + @@ -3254,28 +3291,33 @@ export const SOC2BadgeIcon = (props: SVGProps) => ( ) -export const HIPAABadgeIcon = (props: SVGProps) => ( - - - - - - - - - - - - -) +export function HIPAABadgeIcon(props: SVGProps) { + const id = useId() + const clipId = `hipaa_clip_${id}` + + return ( + + + + + + + + + + + + + ) +} export function GoogleFormsIcon(props: SVGProps) { return ( @@ -3292,19 +3334,6 @@ export function GoogleFormsIcon(props: SVGProps) { d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z' fill='#F1F1F1' /> - - - - - - ) } @@ -3753,6 +3782,9 @@ export function SentryIcon(props: SVGProps) { } export function IncidentioIcon(props: SVGProps) { + const id = useId() + const clipId = `incidentio_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -3995,6 +4027,9 @@ export function SftpIcon(props: SVGProps) { } export function ApifyIcon(props: SVGProps) { + const id = useId() + const clipId = `apify_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4128,6 +4163,9 @@ export function TextractIcon(props: SVGProps) { } export function McpIcon(props: SVGProps) { + const id = useId() + const clipId = `mcp_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4478,6 +4516,10 @@ export function GrainIcon(props: SVGProps) { } export function CirclebackIcon(props: SVGProps) { + const id = useId() + const patternId = `circleback_pattern_${id}` + const imageId = `circleback_image_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' xmlnsXlink='http://www.w3.org/1999/xlink' > - + - - + + ) { } export function FirefliesIcon(props: SVGProps) { + const id = useId() + const g1 = `fireflies_g1_${id}` + const g2 = `fireflies_g2_${id}` + const g3 = `fireflies_g3_${id}` + const g4 = `fireflies_g4_${id}` + const g5 = `fireflies_g5_${id}` + const g6 = `fireflies_g6_${id}` + const g7 = `fireflies_g7_${id}` + const g8 = `fireflies_g8_${id}` + return ( ) { ) { ) { ) { ) { ) { ) { ) { - - + + - - + + @@ -4695,10 +4747,13 @@ export function FirefliesIcon(props: SVGProps) { } export function BedrockIcon(props: SVGProps) { + const id = useId() + const gradientId = `bedrock_gradient_${id}` + return ( - + @@ -4706,7 +4761,7 @@ export function BedrockIcon(props: SVGProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 22fe3c8ce..057d87b45 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2302,33 +2302,12 @@ const WorkflowContent = React.memo(() => { window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) - /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ - const onNodesChange = useCallback( - (changes: NodeChange[]) => { - selectedIdsRef.current = null - setDisplayNodes((nds) => { - const updated = applyNodeChanges(changes, nds) - const hasSelectionChange = changes.some((c) => c.type === 'select') - if (!hasSelectionChange) return updated - const resolved = resolveParentChildSelectionConflicts(updated, blocks) - selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) - return resolved - }) - const selectedIds = selectedIdsRef.current as string[] | null - if (selectedIds !== null) { - syncPanelWithSelection(selectedIds) - } - }, - [blocks] - ) - /** - * Updates container dimensions in displayNodes during drag. - * This allows live resizing of containers as their children are dragged. + * Updates container dimensions in displayNodes during drag or keyboard movement. */ - const updateContainerDimensionsDuringDrag = useCallback( - (draggedNodeId: string, draggedNodePosition: { x: number; y: number }) => { - const parentId = blocks[draggedNodeId]?.data?.parentId + const updateContainerDimensionsDuringMove = useCallback( + (movedNodeId: string, movedNodePosition: { x: number; y: number }) => { + const parentId = blocks[movedNodeId]?.data?.parentId if (!parentId) return setDisplayNodes((currentNodes) => { @@ -2336,7 +2315,7 @@ const WorkflowContent = React.memo(() => { if (childNodes.length === 0) return currentNodes const childPositions = childNodes.map((node) => { - const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position + const nodePosition = node.id === movedNodeId ? movedNodePosition : node.position const { width, height } = getBlockDimensions(node.id) return { x: nodePosition.x, y: nodePosition.y, width, height } }) @@ -2367,6 +2346,34 @@ const WorkflowContent = React.memo(() => { [blocks, getBlockDimensions] ) + /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + selectedIdsRef.current = null + setDisplayNodes((nds) => { + const updated = applyNodeChanges(changes, nds) + const hasSelectionChange = changes.some((c) => c.type === 'select') + if (!hasSelectionChange) return updated + const resolved = resolveParentChildSelectionConflicts(updated, blocks) + selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id) + return resolved + }) + const selectedIds = selectedIdsRef.current as string[] | null + if (selectedIds !== null) { + syncPanelWithSelection(selectedIds) + } + + // Handle position changes (e.g., from keyboard arrow key movement) + // Update container dimensions when child nodes are moved + for (const change of changes) { + if (change.type === 'position' && 'position' in change && change.position) { + updateContainerDimensionsDuringMove(change.id, change.position) + } + } + }, + [blocks, updateContainerDimensionsDuringMove] + ) + /** * Effect to resize loops when nodes change (add/remove/position change). * Runs on structural changes only - not during drag (position-only changes). @@ -2611,7 +2618,7 @@ const WorkflowContent = React.memo(() => { // If the node is inside a container, update container dimensions during drag if (currentParentId) { - updateContainerDimensionsDuringDrag(node.id, node.position) + updateContainerDimensionsDuringMove(node.id, node.position) } // Check if this is a starter block - starter blocks should never be in containers @@ -2728,7 +2735,7 @@ const WorkflowContent = React.memo(() => { blocks, getNodeAbsolutePosition, getNodeDepth, - updateContainerDimensionsDuringDrag, + updateContainerDimensionsDuringMove, highlightContainerNode, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index d18a40348..3ec676f50 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1,19 +1,20 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import * as DialogPrimitive from '@radix-ui/react-dialog' -import * as VisuallyHidden from '@radix-ui/react-visually-hidden' -import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Command } from 'cmdk' +import { BookOpen, Layout, ScrollText } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog' +import { createPortal } from 'react-dom' import { useBrandConfig } from '@/lib/branding/branding' import { cn } from '@/lib/core/utils/cn' -import { getToolOperationsIndex } from '@/lib/search/tool-operations' -import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { getAllBlocks } from '@/blocks' -import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchModalStore } from '@/stores/modals/search/store' +import type { + SearchBlockItem, + SearchDocItem, + SearchToolOperationItem, +} from '@/stores/modals/search/types' interface SearchModalProps { open: boolean @@ -38,270 +39,36 @@ interface WorkspaceItem { isCurrent?: boolean } -interface BlockItem { - id: string - name: string - description: string - icon: React.ComponentType - bgColor: string - type: string - config?: any -} - -interface ToolItem { - id: string - name: string - description: string - icon: React.ComponentType - bgColor: string - type: string -} - interface PageItem { id: string name: string - icon: React.ComponentType + icon: React.ComponentType<{ className?: string }> href: string shortcut?: string } -interface DocItem { - id: string - name: string - icon: React.ComponentType - href: string - type: 'main' | 'block' | 'tool' -} - -type SearchItem = { - id: string - name: string - description?: string - icon?: React.ComponentType - bgColor?: string - color?: string - href?: string - shortcut?: string - type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc' - isCurrent?: boolean - blockType?: string - config?: any - operationId?: string - aliases?: string[] -} - -interface SearchResultItemProps { - item: SearchItem - visualIndex: number - isSelected: boolean - onItemClick: (item: SearchItem) => void -} - -const SearchResultItem = memo(function SearchResultItem({ - item, - visualIndex, - isSelected, - onItemClick, -}: SearchResultItemProps) { - const Icon = item.icon - const showColoredIcon = - item.type === 'block' || - item.type === 'trigger' || - item.type === 'tool' || - item.type === 'tool-operation' - const isWorkflow = item.type === 'workflow' - const isWorkspace = item.type === 'workspace' - - const handleClick = useCallback(() => { - onItemClick(item) - }, [onItemClick, item]) - - return ( -
- ) : ( - Icon && ( -
- -
- ) - )} - - )} - - {/* Content */} - - {item.name} - {item.isCurrent && ' (current)'} - - - {/* Shortcut */} - {item.shortcut && ( - - {item.shortcut} - - )} - - ) -}) - -export const SearchModal = memo(function SearchModal({ +export function SearchModal({ open, onOpenChange, workflows = [], workspaces = [], isOnWorkflowPage = false, }: SearchModalProps) { - const [searchQuery, setSearchQuery] = useState('') - const [selectedIndex, setSelectedIndex] = useState(0) const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const brand = useBrandConfig() - const { filterBlocks } = usePermissionConfig() + const inputRef = useRef(null) + const lastSearchRef = useRef('') + const [mounted, setMounted] = useState(false) - const blocks = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] + useEffect(() => { + setMounted(true) + }, []) - const allBlocks = getAllBlocks() - const filteredAllBlocks = filterBlocks(allBlocks) - const regularBlocks = filteredAllBlocks - .filter( - (block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks' - ) - .map( - (block): BlockItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - }) - ) - - const specialBlocks: BlockItem[] = [ - { - id: 'loop', - name: 'Loop', - description: 'Create a Loop', - icon: RepeatIcon, - bgColor: '#2FB3FF', - type: 'loop', - }, - { - id: 'parallel', - name: 'Parallel', - description: 'Parallel Execution', - icon: SplitIcon, - bgColor: '#FEE12B', - type: 'parallel', - }, - ] - - return [...regularBlocks, ...filterBlocks(specialBlocks)] - }, [open, isOnWorkflowPage, filterBlocks]) - - const triggers = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allTriggers = getTriggersForSidebar() - const filteredTriggers = filterBlocks(allTriggers) - const priorityOrder = ['Start', 'Schedule', 'Webhook'] - - const sortedTriggers = filteredTriggers.sort((a, b) => { - const aIndex = priorityOrder.indexOf(a.name) - const bIndex = priorityOrder.indexOf(b.name) - const aHasPriority = aIndex !== -1 - const bHasPriority = bIndex !== -1 - - if (aHasPriority && bHasPriority) return aIndex - bIndex - if (aHasPriority) return -1 - if (bHasPriority) return 1 - return a.name.localeCompare(b.name) - }) - - return sortedTriggers.map( - (block): BlockItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - config: block, - }) - ) - }, [open, isOnWorkflowPage, filterBlocks]) - - const tools = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allBlocks = getAllBlocks() - const filteredAllBlocks = filterBlocks(allBlocks) - return filteredAllBlocks - .filter((block) => !block.hideFromToolbar && block.category === 'tools') - .map( - (block): ToolItem => ({ - id: block.type, - name: block.name, - description: block.description || '', - icon: block.icon, - bgColor: block.bgColor || '#6B7280', - type: block.type, - }) - ) - }, [open, isOnWorkflowPage, filterBlocks]) - - const toolOperations = useMemo(() => { - if (!open || !isOnWorkflowPage) return [] - - const allowedBlockTypes = new Set(tools.map((t) => t.type)) - - return getToolOperationsIndex() - .filter((op) => allowedBlockTypes.has(op.blockType)) - .map((op) => ({ - id: op.id, - name: `${op.serviceName}: ${op.operationName}`, - icon: op.icon, - bgColor: op.bgColor, - blockType: op.blockType, - operationId: op.operationId, - aliases: op.aliases, - })) - }, [open, isOnWorkflowPage, tools]) + const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore( + (state) => state.data + ) const pages = useMemo( (): PageItem[] => [ @@ -328,389 +95,378 @@ export const SearchModal = memo(function SearchModal({ [workspaceId, brand.documentationUrl] ) - const docs = useMemo((): DocItem[] => { - if (!open) return [] - - const allBlocks = getAllBlocks() - const docsItems: DocItem[] = [] - - allBlocks.forEach((block) => { - if (block.docsLink && !block.hideFromToolbar) { - docsItems.push({ - id: `docs-${block.type}`, - name: block.name, - icon: block.icon, - href: block.docsLink, - type: block.category === 'blocks' || block.category === 'triggers' ? 'block' : 'tool', - }) - } - }) - - return docsItems + useEffect(() => { + if (open) { + lastSearchRef.current = '' + requestAnimationFrame(() => { + inputRef.current?.focus() + }) + } }, [open]) - const allItems = useMemo((): SearchItem[] => { - const items: SearchItem[] = [] + const handleSearchChange = useCallback((value: string) => { + const previousValue = lastSearchRef.current + lastSearchRef.current = value - workspaces.forEach((workspace) => { - items.push({ - id: workspace.id, - name: workspace.name, - href: workspace.href, - type: 'workspace', - isCurrent: workspace.isCurrent, + if (previousValue !== value) { + requestAnimationFrame(() => { + const list = document.querySelector('[cmdk-list]') + if (list) { + list.scrollTop = 0 + } }) - }) + } + }, []) - workflows.forEach((workflow) => { - items.push({ - id: workflow.id, - name: workflow.name, - href: workflow.href, - type: 'workflow', - color: workflow.color, - isCurrent: workflow.isCurrent, - }) - }) + useEffect(() => { + if (!open) return - pages.forEach((page) => { - items.push({ - id: page.id, - name: page.name, - icon: page.icon, - href: page.href, - shortcut: page.shortcut, - type: 'page', - }) - }) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onOpenChange(false) + } + } - blocks.forEach((block) => { - items.push({ - id: block.id, - name: block.name, - description: block.description, - icon: block.icon, - bgColor: block.bgColor, - type: 'block', - blockType: block.type, - }) - }) + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, onOpenChange]) - triggers.forEach((trigger) => { - items.push({ - id: trigger.id, - name: trigger.name, - description: trigger.description, - icon: trigger.icon, - bgColor: trigger.bgColor, - type: 'trigger', - blockType: trigger.type, - config: trigger.config, - }) - }) - - tools.forEach((tool) => { - items.push({ - id: tool.id, - name: tool.name, - description: tool.description, - icon: tool.icon, - bgColor: tool.bgColor, - type: 'tool', - blockType: tool.type, - }) - }) - - toolOperations.forEach((op) => { - items.push({ - id: op.id, - name: op.name, - icon: op.icon, - bgColor: op.bgColor, - type: 'tool-operation', - blockType: op.blockType, - operationId: op.operationId, - aliases: op.aliases, - }) - }) - - docs.forEach((doc) => { - items.push({ - id: doc.id, - name: doc.name, - icon: doc.icon, - href: doc.href, - type: 'doc', - }) - }) - - return items - }, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs]) - - const sectionOrder = useMemo( - () => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'], - [] + const handleBlockSelect = useCallback( + (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { + const enableTriggerMode = + type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: block.type, enableTriggerMode }, + }) + ) + onOpenChange(false) + }, + [onOpenChange] ) - const filteredItems = useMemo(() => { - const orderMap = sectionOrder.reduce>( - (acc, type, index) => { - acc[type] = index - return acc - }, - {} as Record - ) + const handleToolOperationSelect = useCallback( + (op: SearchToolOperationItem) => { + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: op.blockType, presetOperation: op.operationId }, + }) + ) + onOpenChange(false) + }, + [onOpenChange] + ) - if (!searchQuery.trim()) { - return [...allItems].sort((a, b) => { - const aOrder = orderMap[a.type] ?? Number.MAX_SAFE_INTEGER - const bOrder = orderMap[b.type] ?? Number.MAX_SAFE_INTEGER - return aOrder - bOrder - }) - } - - const searchResults = searchItems(searchQuery, allItems) - - return searchResults - .sort((a, b) => { - if (a.score !== b.score) { - return b.score - a.score - } - - const aOrder = orderMap[a.item.type] ?? Number.MAX_SAFE_INTEGER - const bOrder = orderMap[b.item.type] ?? Number.MAX_SAFE_INTEGER - if (aOrder !== bOrder) { - return aOrder - bOrder - } - - return a.item.name.localeCompare(b.item.name) - }) - .map((result) => result.item) - }, [allItems, searchQuery, sectionOrder]) - - const groupedItems = useMemo(() => { - const groups: Record = { - workspace: [], - workflow: [], - page: [], - trigger: [], - block: [], - 'tool-operation': [], - tool: [], - doc: [], - } - - filteredItems.forEach((item) => { - if (groups[item.type]) { - groups[item.type].push(item) - } - }) - - return groups - }, [filteredItems]) - - const displayedItemsInVisualOrder = useMemo(() => { - const visualOrder: SearchItem[] = [] - - sectionOrder.forEach((type) => { - const items = groupedItems[type] || [] - items.forEach((item) => { - visualOrder.push(item) - }) - }) - - return visualOrder - }, [groupedItems, sectionOrder]) - - useEffect(() => { - setSelectedIndex(0) - }, [displayedItemsInVisualOrder]) - - useEffect(() => { - if (!open) { - setSearchQuery('') - setSelectedIndex(0) - } - }, [open]) - - const handleItemClick = useCallback( - (item: SearchItem) => { - switch (item.type) { - case 'block': - case 'trigger': - case 'tool': - if (item.blockType) { - const enableTriggerMode = - item.type === 'trigger' && item.config ? hasTriggerCapability(item.config) : false - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: item.blockType, - enableTriggerMode, - }, - }) - window.dispatchEvent(event) - } - break - case 'tool-operation': - if (item.blockType && item.operationId) { - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: item.blockType, - presetOperation: item.operationId, - }, - }) - window.dispatchEvent(event) - } - break - case 'workspace': - if (item.isCurrent) { - break - } - if (item.href) { - router.push(item.href) - } - break - case 'workflow': - if (!item.isCurrent && item.href) { - router.push(item.href) - window.dispatchEvent( - new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } }) - ) - } - break - case 'page': - case 'doc': - if (item.href) { - if (item.href.startsWith('http')) { - window.open(item.href, '_blank', 'noopener,noreferrer') - } else { - router.push(item.href) - } - } - break + 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 } }) + ) } onOpenChange(false) }, [router, onOpenChange] ) - useEffect(() => { - if (!open) return - - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - setSelectedIndex((prev) => Math.min(prev + 1, displayedItemsInVisualOrder.length - 1)) - break - case 'ArrowUp': - e.preventDefault() - setSelectedIndex((prev) => Math.max(prev - 1, 0)) - break - case 'Enter': - e.preventDefault() - if (displayedItemsInVisualOrder[selectedIndex]) { - handleItemClick(displayedItemsInVisualOrder[selectedIndex]) - } - break - case 'Escape': - e.preventDefault() - onOpenChange(false) - break + const handleWorkspaceSelect = useCallback( + (workspace: WorkspaceItem) => { + if (!workspace.isCurrent && workspace.href) { + router.push(workspace.href) } - } - - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [open, selectedIndex, displayedItemsInVisualOrder, handleItemClick, onOpenChange]) - - useEffect(() => { - if (open && selectedIndex >= 0) { - const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`) - if (element) { - element.scrollIntoView({ - block: 'nearest', - behavior: 'auto', - }) - } - } - }, [selectedIndex, open]) - - const sectionTitles: Record = { - workspace: 'Workspaces', - workflow: 'Workflows', - page: 'Pages', - trigger: 'Triggers', - block: 'Blocks', - 'tool-operation': 'Tool Operations', - tool: 'Tools', - doc: 'Docs', - } - - return ( - - - - - - Search - - - {/* Search input container */} -
- - setSearchQuery(e.target.value)} - className='w-full border-0 bg-transparent font-base text-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none' - autoFocus - /> -
- - {/* Floating results container */} - {displayedItemsInVisualOrder.length > 0 ? ( -
- {sectionOrder.map((type) => { - const items = groupedItems[type] || [] - if (items.length === 0) return null - - return ( -
- {/* Section header */} -
- {sectionTitles[type]} -
- - {/* Section items */} -
- {items.map((item) => { - const visualIndex = displayedItemsInVisualOrder.indexOf(item) - return ( - - ) - })} -
-
- ) - })} -
- ) : searchQuery ? ( -
-

- No results found for "{searchQuery}" -

-
- ) : null} -
-
-
+ onOpenChange(false) + }, + [router, onOpenChange] ) -}) + + const handlePageSelect = useCallback( + (page: PageItem) => { + if (page.href.startsWith('http')) { + window.open(page.href, '_blank', 'noopener,noreferrer') + } else { + router.push(page.href) + } + onOpenChange(false) + }, + [router, onOpenChange] + ) + + const handleDocSelect = useCallback( + (doc: SearchDocItem) => { + window.open(doc.href, '_blank', 'noopener,noreferrer') + onOpenChange(false) + }, + [onOpenChange] + ) + + const showBlocks = isOnWorkflowPage && blocks.length > 0 + const showTools = isOnWorkflowPage && tools.length > 0 + const showTriggers = isOnWorkflowPage && triggers.length > 0 + const showToolOperations = isOnWorkflowPage && toolOperations.length > 0 + const showDocs = isOnWorkflowPage && docs.length > 0 + + const customFilter = useCallback((value: string, search: string) => { + const valueLower = value.toLowerCase() + const searchLower = search.toLowerCase() + + if (valueLower === searchLower) return 1 + if (valueLower.startsWith(searchLower)) return 0.8 + if (valueLower.includes(searchLower)) return 0.6 + + const searchWords = searchLower.split(/\s+/).filter(Boolean) + const allWordsMatch = searchWords.every((word) => valueLower.includes(word)) + if (allWordsMatch && searchWords.length > 0) return 0.4 + + return 0 + }, []) + + if (!mounted) return null + + return createPortal( + <> + {/* Overlay */} +
onOpenChange(false)} + aria-hidden={!open} + /> + + {/* Command palette - only render when open to ensure SVG gradients work */} + {open && ( +
+ + + + + No results found. + + + {showBlocks && ( + + {blocks.map((block) => ( + handleBlockSelect(block, 'block')} + icon={block.icon} + bgColor={block.bgColor} + showColoredIcon + > + {block.name} + + ))} + + )} + + {showTools && ( + + {tools.map((tool) => ( + handleBlockSelect(tool, 'tool')} + icon={tool.icon} + bgColor={tool.bgColor} + showColoredIcon + > + {tool.name} + + ))} + + )} + + {showTriggers && ( + + {triggers.map((trigger) => ( + handleBlockSelect(trigger, 'trigger')} + icon={trigger.icon} + bgColor={trigger.bgColor} + showColoredIcon + > + {trigger.name} + + ))} + + )} + + {workflows.length > 0 && ( + + {workflows.map((workflow) => ( + handleWorkflowSelect(workflow)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > +
+ + {workflow.name} + {workflow.isCurrent && ' (current)'} + + + ))} + + )} + + {showToolOperations && ( + + {toolOperations.map((op) => ( + handleToolOperationSelect(op)} + icon={op.icon} + bgColor={op.bgColor} + showColoredIcon + > + {op.name} + + ))} + + )} + + {workspaces.length > 0 && ( + + {workspaces.map((workspace) => ( + handleWorkspaceSelect(workspace)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > + + {workspace.name} + {workspace.isCurrent && ' (current)'} + + + ))} + + )} + + {showDocs && ( + + {docs.map((doc) => ( + handleDocSelect(doc)} + icon={doc.icon} + bgColor='#6B7280' + showColoredIcon + > + {doc.name} + + ))} + + )} + + {pages.length > 0 && ( + + {pages.map((page) => { + const Icon = page.icon + return ( + handlePageSelect(page)} + className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + > +
+ +
+ + {page.name} + + {page.shortcut && ( + + {page.shortcut} + + )} +
+ ) + })} +
+ )} + + +
+ )} + , + document.body + ) +} + +const groupHeadingClassName = + '[&_[cmdk-group-heading]]:pt-[2px] [&_[cmdk-group-heading]]:pb-[4px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[13px] [&_[cmdk-group-heading]]:text-[var(--text-subtle)] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide' + +interface CommandItemProps { + value: string + keywords?: string[] + onSelect: () => void + icon: React.ComponentType<{ className?: string }> + bgColor: string + showColoredIcon?: boolean + children: React.ReactNode +} + +function CommandItem({ + value, + keywords, + onSelect, + icon: Icon, + bgColor, + showColoredIcon, + children, +}: CommandItemProps) { + return ( + +
+ +
+ + {children} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts deleted file mode 100644 index 08525b16f..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Search utility functions for tiered matching algorithm - * Provides predictable search results prioritizing exact matches over fuzzy matches - */ - -export interface SearchableItem { - id: string - name: string - description?: string - type: string - aliases?: string[] - [key: string]: any -} - -export interface SearchResult { - item: T - score: number - matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description' -} - -const SCORE_EXACT_MATCH = 10000 -const SCORE_PREFIX_MATCH = 5000 -const SCORE_ALIAS_MATCH = 3000 -const SCORE_WORD_BOUNDARY = 1000 -const SCORE_SUBSTRING_MATCH = 100 -const DESCRIPTION_WEIGHT = 0.3 - -/** - * Calculate match score for a single field - * Returns 0 if no match found - */ -function calculateFieldScore( - query: string, - field: string -): { - score: number - matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | null -} { - const normalizedQuery = query.toLowerCase().trim() - const normalizedField = field.toLowerCase().trim() - - if (!normalizedQuery || !normalizedField) { - return { score: 0, matchType: null } - } - - // Tier 1: Exact match - if (normalizedField === normalizedQuery) { - return { score: SCORE_EXACT_MATCH, matchType: 'exact' } - } - - // Tier 2: Prefix match (starts with query) - if (normalizedField.startsWith(normalizedQuery)) { - return { score: SCORE_PREFIX_MATCH, matchType: 'prefix' } - } - - // Tier 3: Word boundary match (query matches start of a word) - const words = normalizedField.split(/[\s-_/]+/) - const hasWordBoundaryMatch = words.some((word) => word.startsWith(normalizedQuery)) - if (hasWordBoundaryMatch) { - return { score: SCORE_WORD_BOUNDARY, matchType: 'word-boundary' } - } - - // Tier 4: Substring match (query appears anywhere) - if (normalizedField.includes(normalizedQuery)) { - return { score: SCORE_SUBSTRING_MATCH, matchType: 'substring' } - } - - // No match - return { score: 0, matchType: null } -} - -/** - * Check if query matches any alias in the item's aliases array - * Returns the alias score if a match is found, 0 otherwise - */ -function calculateAliasScore( - query: string, - aliases?: string[] -): { score: number; matchType: 'alias' | null } { - if (!aliases || aliases.length === 0) { - return { score: 0, matchType: null } - } - - const normalizedQuery = query.toLowerCase().trim() - - for (const alias of aliases) { - const normalizedAlias = alias.toLowerCase().trim() - - if (normalizedAlias === normalizedQuery) { - return { score: SCORE_ALIAS_MATCH, matchType: 'alias' } - } - - if (normalizedAlias.startsWith(normalizedQuery)) { - return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' } - } - - if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) { - return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' } - } - } - - return { score: 0, matchType: null } -} - -/** - * Calculate multi-word match score - * Each word in the query must appear somewhere in the field - * Returns a score based on how well the words match - */ -function calculateMultiWordScore( - queryWords: string[], - field: string -): { score: number; matchType: 'word-boundary' | 'substring' | null } { - const normalizedField = field.toLowerCase().trim() - const fieldWords = normalizedField.split(/[\s\-_/:]+/) - - let allWordsMatch = true - let totalScore = 0 - let hasWordBoundary = false - - for (const queryWord of queryWords) { - const wordBoundaryMatch = fieldWords.some((fw) => fw.startsWith(queryWord)) - const substringMatch = normalizedField.includes(queryWord) - - if (wordBoundaryMatch) { - totalScore += SCORE_WORD_BOUNDARY - hasWordBoundary = true - } else if (substringMatch) { - totalScore += SCORE_SUBSTRING_MATCH - } else { - allWordsMatch = false - break - } - } - - if (!allWordsMatch) { - return { score: 0, matchType: null } - } - - return { - score: totalScore / queryWords.length, - matchType: hasWordBoundary ? 'word-boundary' : 'substring', - } -} - -/** - * Search items using tiered matching algorithm - * Returns items sorted by relevance (highest score first) - */ -export function searchItems( - query: string, - items: T[] -): SearchResult[] { - const normalizedQuery = query.trim() - - if (!normalizedQuery) { - return [] - } - - const results: SearchResult[] = [] - const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean) - const isMultiWord = queryWords.length > 1 - - for (const item of items) { - const nameMatch = calculateFieldScore(normalizedQuery, item.name) - - const descMatch = item.description - ? calculateFieldScore(normalizedQuery, item.description) - : { score: 0, matchType: null } - - const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases) - - let nameScore = nameMatch.score - let descScore = descMatch.score * DESCRIPTION_WEIGHT - const aliasScore = aliasMatch.score - - let bestMatchType = nameMatch.matchType - - // For multi-word queries, also try matching each word independently and take the better score - if (isMultiWord) { - const multiWordNameMatch = calculateMultiWordScore(queryWords, item.name) - if (multiWordNameMatch.score > nameScore) { - nameScore = multiWordNameMatch.score - bestMatchType = multiWordNameMatch.matchType - } - - if (item.description) { - const multiWordDescMatch = calculateMultiWordScore(queryWords, item.description) - const multiWordDescScore = multiWordDescMatch.score * DESCRIPTION_WEIGHT - if (multiWordDescScore > descScore) { - descScore = multiWordDescScore - } - } - } - - const bestScore = Math.max(nameScore, descScore, aliasScore) - - if (bestScore > 0) { - let matchType: SearchResult['matchType'] = 'substring' - if (nameScore >= descScore && nameScore >= aliasScore) { - matchType = bestMatchType || 'substring' - } else if (aliasScore >= descScore) { - matchType = 'alias' - } else { - matchType = 'description' - } - - results.push({ - item, - score: bestScore, - matchType, - }) - } - } - - results.sort((a, b) => b.score - a.score) - - return results -} - -/** - * Get a human-readable match type label - */ -export function getMatchTypeLabel(matchType: SearchResult['matchType']): string { - switch (matchType) { - case 'exact': - return 'Exact match' - case 'prefix': - return 'Starts with' - case 'alias': - return 'Similar to' - case 'word-boundary': - return 'Word match' - case 'substring': - return 'Contains' - case 'description': - return 'In description' - default: - return 'Match' - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 5d5f36dc3..407e8b9ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -73,7 +73,12 @@ export const Sidebar = memo(function Sidebar() { const { data: sessionData, isPending: sessionLoading } = useSession() const { canEdit } = useUserPermissionsContext() - const { config: permissionConfig } = usePermissionConfig() + const { config: permissionConfig, filterBlocks } = usePermissionConfig() + const initializeSearchData = useSearchModalStore((state) => state.initializeData) + + useEffect(() => { + initializeSearchData(filterBlocks) + }, [initializeSearchData, filterBlocks]) /** * Sidebar state from store with hydration tracking to prevent SSR mismatch. diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 9c901860a..f9b47f43c 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react' +import { useId } from 'react' export function SearchIcon(props: SVGProps) { return ( @@ -737,6 +738,9 @@ export function GmailIcon(props: SVGProps) { } export function GrafanaIcon(props: SVGProps) { + const id = useId() + const gradientId = `grafana_gradient_${id}` + return ( ) { fill='none' > ) { } export function SupabaseIcon(props: SVGProps) { + const id = useId() + const gradient0 = `supabase_paint0_${id}` + const gradient1 = `supabase_paint1_${id}` + return ( ) { > ) { /> ) { ) { } export function MistralIcon(props: SVGProps) { + const id = useId() + const clipId = `mistral_clip_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMidYMid meet' > - + ) { /> - + @@ -2126,6 +2137,9 @@ export function MicrosoftIcon(props: SVGProps) { } export function MicrosoftTeamsIcon(props: SVGProps) { + const id = useId() + const gradientId = `msteams_gradient_${id}` + return ( ) { d='M1140.333,561.355v103.148c-104.963-24.857-191.679-98.469-233.25-198.003 h138.395C1097.783,466.699,1140.134,509.051,1140.333,561.355z' /> ) { ) { } export function OutlookIcon(props: SVGProps) { + const id = useId() + const gradient1 = `outlook_gradient1_${id}` + const gradient2 = `outlook_gradient2_${id}` + return ( ) { ) { ) { d='M936.833,461.305v823.136c-0.046,43.067-34.861,78.015-77.927,78.225H425.833 V383.25h433.072c43.062,0.023,77.951,34.951,77.927,78.013C936.833,461.277,936.833,461.291,936.833,461.305z' /> ) { ) { } export function MicrosoftExcelIcon(props: SVGProps) { + const id = useId() + const gradientId = `excel_gradient_${id}` + return ( ) { d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z' /> ) { ) => ( ) -export const AzureIcon = (props: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - -) +export function AzureIcon(props: SVGProps) { + const id = useId() + const gradient0 = `azure_paint0_${id}` + const gradient1 = `azure_paint1_${id}` + const gradient2 = `azure_paint2_${id}` + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} export const GroqIcon = (props: SVGProps) => ( ) => ( ) -export const GeminiIcon = (props: SVGProps) => ( - - Gemini - - - - - - - - - -) +export function GeminiIcon(props: SVGProps) { + const id = useId() + const gradientId = `gemini_gradient_${id}` + + return ( + + Gemini + + + + + + + + + + ) +} export const VertexIcon = (props: SVGProps) => ( ) { } export function QdrantIcon(props: SVGProps) { + const id = useId() + const gradientId = `qdrant_gradient_${id}` + const clipPathId = `qdrant_clippath_${id}` + return ( - + ) { /> ) { - + @@ -3254,28 +3291,33 @@ export const SOC2BadgeIcon = (props: SVGProps) => ( ) -export const HIPAABadgeIcon = (props: SVGProps) => ( - - - - - - - - - - - - -) +export function HIPAABadgeIcon(props: SVGProps) { + const id = useId() + const clipId = `hipaa_clip_${id}` + + return ( + + + + + + + + + + + + + ) +} export function GoogleFormsIcon(props: SVGProps) { return ( @@ -3292,19 +3334,6 @@ export function GoogleFormsIcon(props: SVGProps) { d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z' fill='#F1F1F1' /> - - - - - - ) } @@ -3753,6 +3782,9 @@ export function SentryIcon(props: SVGProps) { } export function IncidentioIcon(props: SVGProps) { + const id = useId() + const clipId = `incidentio_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -3995,6 +4027,9 @@ export function SftpIcon(props: SVGProps) { } export function ApifyIcon(props: SVGProps) { + const id = useId() + const clipId = `apify_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4128,6 +4163,9 @@ export function TextractIcon(props: SVGProps) { } export function McpIcon(props: SVGProps) { + const id = useId() + const clipId = `mcp_clip_${id}` + return ( ) { fill='none' xmlns='http://www.w3.org/2000/svg' > - + ) { /> - + @@ -4478,6 +4516,10 @@ export function GrainIcon(props: SVGProps) { } export function CirclebackIcon(props: SVGProps) { + const id = useId() + const patternId = `circleback_pattern_${id}` + const imageId = `circleback_image_${id}` + return ( ) { xmlns='http://www.w3.org/2000/svg' xmlnsXlink='http://www.w3.org/1999/xlink' > - + - - + + ) { } export function FirefliesIcon(props: SVGProps) { + const id = useId() + const g1 = `fireflies_g1_${id}` + const g2 = `fireflies_g2_${id}` + const g3 = `fireflies_g3_${id}` + const g4 = `fireflies_g4_${id}` + const g5 = `fireflies_g5_${id}` + const g6 = `fireflies_g6_${id}` + const g7 = `fireflies_g7_${id}` + const g8 = `fireflies_g8_${id}` + return ( ) { ) { ) { ) { ) { ) { ) { ) { - - + + - - + + @@ -4695,10 +4747,13 @@ export function FirefliesIcon(props: SVGProps) { } export function BedrockIcon(props: SVGProps) { + const id = useId() + const gradientId = `bedrock_gradient_${id}` + return ( - + @@ -4706,7 +4761,7 @@ export function BedrockIcon(props: SVGProps) { diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index c41e20f7a..f52fc66a5 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,15 +1,154 @@ +import { RepeatIcon, SplitIcon } from 'lucide-react' import { create } from 'zustand' -import type { SearchModalState } from './types' +import { getToolOperationsIndex } from '@/lib/search/tool-operations' +import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' +import { getAllBlocks } from '@/blocks' +import type { + SearchBlockItem, + SearchData, + SearchDocItem, + SearchModalState, + SearchToolOperationItem, +} from './types' -export const useSearchModalStore = create((set) => ({ +const initialData: SearchData = { + blocks: [], + tools: [], + triggers: [], + toolOperations: [], + docs: [], + isInitialized: false, +} + +export const useSearchModalStore = create((set, get) => ({ isOpen: false, + data: initialData, + setOpen: (open: boolean) => { set({ isOpen: open }) }, + open: () => { set({ isOpen: true }) }, + close: () => { set({ isOpen: false }) }, + + initializeData: (filterBlocks) => { + if (get().data.isInitialized) return + + // Cache getAllBlocks result to avoid redundant calls + const allBlocks = getAllBlocks() + const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks + + // Process blocks, tools, triggers, and docs in a single pass + const regularBlocks: SearchBlockItem[] = [] + const tools: SearchBlockItem[] = [] + const docs: SearchDocItem[] = [] + + for (const block of filteredAllBlocks) { + if (block.hideFromToolbar) continue + + const searchItem: SearchBlockItem = { + id: block.type, + name: block.name, + description: block.description || '', + icon: block.icon, + bgColor: block.bgColor || '#6B7280', + type: block.type, + } + + if (block.category === 'blocks' && block.type !== 'starter') { + regularBlocks.push(searchItem) + } else if (block.category === 'tools') { + tools.push(searchItem) + } + + if (block.docsLink) { + docs.push({ + id: `docs-${block.type}`, + name: block.name, + icon: block.icon, + href: block.docsLink, + }) + } + } + + const specialBlocks: SearchBlockItem[] = [ + { + id: 'loop', + name: 'Loop', + description: 'Create a Loop', + icon: RepeatIcon, + bgColor: '#2FB3FF', + type: 'loop', + }, + { + id: 'parallel', + name: 'Parallel', + description: 'Parallel Execution', + icon: SplitIcon, + bgColor: '#FEE12B', + type: 'parallel', + }, + ] + + const blocks = [...regularBlocks, ...(filterBlocks(specialBlocks) as SearchBlockItem[])] + + // getTriggersForSidebar filters from allBlocks internally, pass allBlocks to avoid re-fetch + const allTriggers = getTriggersForSidebar() + const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers + const priorityOrder = ['Start', 'Schedule', 'Webhook'] + + const sortedTriggers = [...filteredTriggers].sort((a, b) => { + const aIndex = priorityOrder.indexOf(a.name) + const bIndex = priorityOrder.indexOf(b.name) + const aHasPriority = aIndex !== -1 + const bHasPriority = bIndex !== -1 + + if (aHasPriority && bHasPriority) return aIndex - bIndex + if (aHasPriority) return -1 + if (bHasPriority) return 1 + return a.name.localeCompare(b.name) + }) + + const triggers = sortedTriggers.map( + (block): SearchBlockItem => ({ + id: block.type, + name: block.name, + description: block.description || '', + icon: block.icon, + bgColor: block.bgColor || '#6B7280', + type: block.type, + config: block, + }) + ) + + const allowedBlockTypes = new Set(tools.map((t) => t.type)) + const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() + .filter((op) => allowedBlockTypes.has(op.blockType)) + .map((op) => ({ + id: op.id, + name: op.operationName, + searchValue: `${op.serviceName} ${op.operationName}`, + icon: op.icon, + bgColor: op.bgColor, + blockType: op.blockType, + operationId: op.operationId, + keywords: op.aliases, + })) + + set({ + data: { + blocks, + tools, + triggers, + toolOperations, + docs, + isInitialized: true, + }, + }) + }, })) diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index c3170e951..d904d8314 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -1,3 +1,55 @@ +import type { ComponentType } from 'react' +import type { BlockConfig } from '@/blocks/types' + +/** + * Represents a block item in the search results. + */ +export interface SearchBlockItem { + id: string + name: string + description: string + icon: ComponentType<{ className?: string }> + bgColor: string + type: string + config?: BlockConfig +} + +/** + * Represents a tool operation item in the search results. + */ +export interface SearchToolOperationItem { + id: string + name: string + searchValue: string + icon: ComponentType<{ className?: string }> + bgColor: string + blockType: string + operationId: string + keywords: string[] +} + +/** + * Represents a doc item in the search results. + */ +export interface SearchDocItem { + id: string + name: string + icon: ComponentType<{ className?: string }> + href: string +} + +/** + * Pre-computed search data that is initialized on app load. + */ +export interface SearchData { + blocks: SearchBlockItem[] + tools: SearchBlockItem[] + triggers: SearchBlockItem[] + toolOperations: SearchToolOperationItem[] + docs: SearchDocItem[] + isInitialized: boolean +} + /** * Global state for the universal search modal. * @@ -8,18 +60,27 @@ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean + + /** Pre-computed search data. */ + data: SearchData + /** * Explicitly set the open state of the modal. - * - * @param open - New open state. */ setOpen: (open: boolean) => void + /** * Convenience method to open the modal. */ open: () => void + /** * Convenience method to close the modal. */ close: () => void + + /** + * Initialize search data. Called once on app load. + */ + initializeData: (filterBlocks: (blocks: unknown[]) => unknown[]) => void }