Skip to content

Commit 05cd7d9

Browse files
authored
feat(search): actions, fuzzy matching, and highlighting in cmd+k palette (#5110)
* feat(search): actions, fuzzy matching, and highlighting in cmd+k palette Add a context-aware actions layer to the cmd+k search palette (Run workflow, Create workflow/folder, Import workflow, Fit to view, Copy link, Invite teammates, Toggle theme), replace the substring matcher with a boundary-anchored fuzzy matcher (initialisms, typos, multi-word) that is a strict superset of the old behavior, highlight matched characters, and rank against clean human text instead of structural id/uuid tokens. Expose invoke() on the global commands provider so the palette runs real registered commands. * fix(search): highlight the matched substring, not an earlier scattered occurrence Contiguous substring matches (exact/prefix/contains) now report the substring's own indices instead of the greedy subsequence scan positions, so HighlightedText bolds the characters the user actually matched. Restructures fuzzyMatch to handle the substring tier first; scores are unchanged for these cases. * fix(search): log clipboard copy failures and make fuzzy positions read-only - Copy workflow link now logs on clipboard write failure instead of silently swallowing the error, matching the sidebar's copy-link convention. - FuzzyResult.positions is now readonly and the NO_MATCH singleton's array is frozen, so the shared instance can never be mutated by a caller.
1 parent 8b93e43 commit 05cd7d9

11 files changed

Lines changed: 897 additions & 93 deletions

File tree

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface RegistryCommand extends GlobalCommand {
3939

4040
interface GlobalCommandsContextValue {
4141
register: (commands: GlobalCommand[]) => () => void
42+
invoke: (id: string) => boolean
4243
}
4344

4445
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
@@ -142,11 +143,39 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
142143
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
143144
}, [isMac, router])
144145

145-
const value = useMemo<GlobalCommandsContextValue>(() => ({ register }), [register])
146+
const invoke = useCallback((id: string): boolean => {
147+
const cmd = registryRef.current.get(id)
148+
if (!cmd) return false
149+
try {
150+
cmd.handler(new KeyboardEvent('keydown'))
151+
} catch (err) {
152+
logger.error('Global command handler threw', { id, err })
153+
}
154+
return true
155+
}, [])
156+
157+
const value = useMemo<GlobalCommandsContextValue>(
158+
() => ({ register, invoke }),
159+
[register, invoke]
160+
)
146161

147162
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
148163
}
149164

165+
/**
166+
* Returns a function that runs a registered global command by id, mirroring its
167+
* keyboard shortcut exactly. Returns `false` when no command with that id is
168+
* currently registered (e.g. a workflow-only command invoked off-canvas), so
169+
* callers can offer the action safely without knowing what is mounted.
170+
*/
171+
export function useInvokeGlobalCommand(): (id: string) => boolean {
172+
const ctx = useContext(GlobalCommandsContext)
173+
if (!ctx) {
174+
throw new Error('useInvokeGlobalCommand must be used within GlobalCommandsProvider')
175+
}
176+
return ctx.invoke
177+
}
178+
150179
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
151180
const ctx = useContext(GlobalCommandsContext)
152181
if (!ctx) {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx

Lines changed: 137 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,54 @@ import { Command } from 'cmdk'
66
import { File, Workflow } from '@/components/emcn/icons'
77
import { cn } from '@/lib/core/utils/cn'
88
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
9-
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
9+
import {
10+
COMMAND_ITEM_CLASSNAME,
11+
fuzzyMatch,
12+
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
13+
14+
interface Segment {
15+
text: string
16+
hit: boolean
17+
}
18+
19+
function buildSegments(text: string, positions: readonly number[]): Segment[] {
20+
const hits = new Set(positions)
21+
const segments: Segment[] = []
22+
for (let i = 0; i < text.length; i++) {
23+
const hit = hits.has(i)
24+
const last = segments[segments.length - 1]
25+
if (last && last.hit === hit) last.text += text[i]
26+
else segments.push({ text: text[i], hit })
27+
}
28+
return segments
29+
}
30+
31+
/**
32+
* Renders `text` with the characters that match `query` emphasized. Falls back
33+
* to plain text when there is no query or no positional match against the
34+
* display text (e.g. the row matched on a hidden id rather than its label).
35+
*/
36+
export const HighlightedText = memo(
37+
function HighlightedText({ text, query }: { text: string; query?: string }) {
38+
if (!query) return <>{text}</>
39+
const { positions } = fuzzyMatch(text, query)
40+
if (positions.length === 0) return <>{text}</>
41+
return (
42+
<>
43+
{buildSegments(text, positions).map((segment, index) =>
44+
segment.hit ? (
45+
<span key={index} className='font-semibold text-[var(--text-body)]'>
46+
{segment.text}
47+
</span>
48+
) : (
49+
<span key={index}>{segment.text}</span>
50+
)
51+
)}
52+
</>
53+
)
54+
},
55+
(prev, next) => prev.text === next.text && prev.query === next.query
56+
)
1057

1158
export const MemoizedCommandItem = memo(
1259
function CommandItem({
@@ -15,7 +62,8 @@ export const MemoizedCommandItem = memo(
1562
icon: Icon,
1663
bgColor,
1764
showColoredIcon,
18-
children,
65+
label,
66+
query,
1967
}: CommandItemProps) {
2068
return (
2169
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
@@ -32,7 +80,9 @@ export const MemoizedCommandItem = memo(
3280
)}
3381
/>
3482
</div>
35-
<span className='truncate text-[var(--text-body)]'>{children}</span>
83+
<span className='truncate text-[var(--text-body)]'>
84+
<HighlightedText text={label} query={query} />
85+
</span>
3686
</Command.Item>
3787
)
3888
},
@@ -41,7 +91,46 @@ export const MemoizedCommandItem = memo(
4191
prev.icon === next.icon &&
4292
prev.bgColor === next.bgColor &&
4393
prev.showColoredIcon === next.showColoredIcon &&
44-
prev.children === next.children
94+
prev.label === next.label &&
95+
prev.query === next.query
96+
)
97+
98+
export const MemoizedActionItem = memo(
99+
function ActionItem({
100+
value,
101+
onSelect,
102+
icon: Icon,
103+
name,
104+
shortcut,
105+
query,
106+
}: {
107+
value: string
108+
onSelect: () => void
109+
icon: ComponentType<{ className?: string }>
110+
name: string
111+
shortcut?: string
112+
query?: string
113+
}) {
114+
return (
115+
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
116+
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
117+
<span className='truncate text-[var(--text-body)]'>
118+
<HighlightedText text={name} query={query} />
119+
</span>
120+
{shortcut && (
121+
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
122+
{shortcut}
123+
</span>
124+
)}
125+
</Command.Item>
126+
)
127+
},
128+
(prev, next) =>
129+
prev.value === next.value &&
130+
prev.icon === next.icon &&
131+
prev.name === next.name &&
132+
prev.shortcut === next.shortcut &&
133+
prev.query === next.query
45134
)
46135

47136
export const MemoizedWorkflowItem = memo(
@@ -51,20 +140,24 @@ export const MemoizedWorkflowItem = memo(
51140
name,
52141
folderPath,
53142
isCurrent,
143+
query,
54144
}: {
55145
value: string
56146
onSelect: () => void
57147
name: string
58148
folderPath?: string[]
59149
isCurrent?: boolean
150+
query?: string
60151
}) {
61152
return (
62153
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
63154
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
64155
<Workflow className='size-[14px] text-[var(--text-icon)]' />
65156
</div>
66157
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
67-
<span className='truncate'>{name}</span>
158+
<span className='truncate'>
159+
<HighlightedText text={name} query={query} />
160+
</span>
68161
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
69162
</span>
70163
{folderPath && folderPath.length > 0 && (
@@ -87,6 +180,7 @@ export const MemoizedWorkflowItem = memo(
87180
prev.value === next.value &&
88181
prev.name === next.name &&
89182
prev.isCurrent === next.isCurrent &&
183+
prev.query === next.query &&
90184
(prev.folderPath === next.folderPath ||
91185
(prev.folderPath?.length === next.folderPath?.length &&
92186
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
@@ -98,19 +192,23 @@ export const MemoizedFileItem = memo(
98192
onSelect,
99193
name,
100194
folderPath,
195+
query,
101196
}: {
102197
value: string
103198
onSelect: () => void
104199
name: string
105200
folderPath?: string[]
201+
query?: string
106202
}) {
107203
return (
108204
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
109205
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
110206
<File className='size-[14px] text-[var(--text-icon)]' />
111207
</div>
112208
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
113-
<span className='truncate'>{name}</span>
209+
<span className='truncate'>
210+
<HighlightedText text={name} query={query} />
211+
</span>
114212
</span>
115213
{folderPath && folderPath.length > 0 && (
116214
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
@@ -131,6 +229,7 @@ export const MemoizedFileItem = memo(
131229
(prev, next) =>
132230
prev.value === next.value &&
133231
prev.name === next.name &&
232+
prev.query === next.query &&
134233
(prev.folderPath === next.folderPath ||
135234
(prev.folderPath?.length === next.folderPath?.length &&
136235
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
@@ -141,18 +240,22 @@ export const MemoizedTaskItem = memo(
141240
value,
142241
onSelect,
143242
name,
243+
query,
144244
}: {
145245
value: string
146246
onSelect: () => void
147247
name: string
248+
query?: string
148249
}) {
149250
return (
150251
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
151-
<span className='truncate text-[var(--text-body)]'>{name}</span>
252+
<span className='truncate text-[var(--text-body)]'>
253+
<HighlightedText text={name} query={query} />
254+
</span>
152255
</Command.Item>
153256
)
154257
},
155-
(prev, next) => prev.value === next.value && prev.name === next.name
258+
(prev, next) => prev.value === next.value && prev.name === next.name && prev.query === next.query
156259
)
157260

158261
export const MemoizedWorkspaceItem = memo(
@@ -161,23 +264,30 @@ export const MemoizedWorkspaceItem = memo(
161264
onSelect,
162265
name,
163266
isCurrent,
267+
query,
164268
}: {
165269
value: string
166270
onSelect: () => void
167271
name: string
168272
isCurrent?: boolean
273+
query?: string
169274
}) {
170275
return (
171276
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
172277
<span className='flex min-w-0 text-[var(--text-body)]'>
173-
<span className='truncate'>{name}</span>
278+
<span className='truncate'>
279+
<HighlightedText text={name} query={query} />
280+
</span>
174281
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
175282
</span>
176283
</Command.Item>
177284
)
178285
},
179286
(prev, next) =>
180-
prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent
287+
prev.value === next.value &&
288+
prev.name === next.name &&
289+
prev.isCurrent === next.isCurrent &&
290+
prev.query === next.query
181291
)
182292

183293
export const MemoizedPageItem = memo(
@@ -187,17 +297,21 @@ export const MemoizedPageItem = memo(
187297
icon: Icon,
188298
name,
189299
shortcut,
300+
query,
190301
}: {
191302
value: string
192303
onSelect: () => void
193304
icon: ComponentType<{ className?: string }>
194305
name: string
195306
shortcut?: string
307+
query?: string
196308
}) {
197309
return (
198310
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
199311
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
200-
<span className='truncate text-[var(--text-body)]'>{name}</span>
312+
<span className='truncate text-[var(--text-body)]'>
313+
<HighlightedText text={name} query={query} />
314+
</span>
201315
{shortcut && (
202316
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
203317
{shortcut}
@@ -210,7 +324,8 @@ export const MemoizedPageItem = memo(
210324
prev.value === next.value &&
211325
prev.icon === next.icon &&
212326
prev.name === next.name &&
213-
prev.shortcut === next.shortcut
327+
prev.shortcut === next.shortcut &&
328+
prev.query === next.query
214329
)
215330

216331
export const MemoizedIconItem = memo(
@@ -219,18 +334,26 @@ export const MemoizedIconItem = memo(
219334
onSelect,
220335
name,
221336
icon: Icon,
337+
query,
222338
}: {
223339
value: string
224340
onSelect: () => void
225341
name: string
226342
icon: ComponentType<{ className?: string }>
343+
query?: string
227344
}) {
228345
return (
229346
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
230347
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
231-
<span className='truncate text-[var(--text-body)]'>{name}</span>
348+
<span className='truncate text-[var(--text-body)]'>
349+
<HighlightedText text={name} query={query} />
350+
</span>
232351
</Command.Item>
233352
)
234353
},
235-
(prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon
354+
(prev, next) =>
355+
prev.value === next.value &&
356+
prev.name === next.name &&
357+
prev.icon === next.icon &&
358+
prev.query === next.query
236359
)

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export {
2+
HighlightedText,
3+
MemoizedActionItem,
24
MemoizedCommandItem,
35
MemoizedFileItem,
46
MemoizedIconItem,

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
ActionsGroup,
23
BlocksGroup,
34
ChatsGroup,
45
ConnectedAccountsGroup,

0 commit comments

Comments
 (0)