Skip to content

Commit 3fb4354

Browse files
committed
improvement(search-replace): dedupe double indexed segments
1 parent 9e9ddaa commit 3fb4354

3 files changed

Lines changed: 163 additions & 1 deletion

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace
99
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
1010
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
1111
import {
12+
dedupeOverlappingWorkflowSearchMatches,
1213
getCompatibleResourceReplacementOptions,
1314
getWorkflowSearchCompatibleResourceMatches,
1415
getWorkflowSearchMatchResourceGroupKey,
@@ -197,7 +198,10 @@ export function WorkflowSearchReplace() {
197198
})
198199

199200
const hydratedMatches = useMemo(
200-
() => allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)),
201+
() =>
202+
dedupeOverlappingWorkflowSearchMatches(
203+
allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query))
204+
),
201205
[allHydratedMatches, query]
202206
)
203207

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { dedupeOverlappingWorkflowSearchMatches } from '@/lib/workflows/search-replace/resources/resolvers'
6+
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
7+
8+
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
9+
return {
10+
id: 'match',
11+
blockId: 'block-1',
12+
blockName: 'Block',
13+
blockType: 'function',
14+
subBlockId: 'code',
15+
canonicalSubBlockId: 'code',
16+
subBlockType: 'code',
17+
valuePath: [],
18+
target: { kind: 'subblock' },
19+
kind: 'text',
20+
rawValue: '',
21+
searchText: '',
22+
editable: true,
23+
navigable: true,
24+
protected: false,
25+
...overrides,
26+
}
27+
}
28+
29+
describe('dedupeOverlappingWorkflowSearchMatches', () => {
30+
it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => {
31+
const textMatch = createMatch({
32+
id: 'text-partial',
33+
kind: 'text',
34+
rawValue: '<start.h',
35+
searchText: "return '<start.hello>'",
36+
range: { start: 8, end: 16 },
37+
})
38+
const referenceMatch = createMatch({
39+
id: 'workflow-reference',
40+
kind: 'workflow-reference',
41+
rawValue: '<start.hello>',
42+
searchText: 'start.hello',
43+
range: { start: 8, end: 21 },
44+
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
45+
})
46+
47+
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch])
48+
})
49+
50+
it('keeps the inline reference when it covers the same span as a text hit', () => {
51+
const textMatch = createMatch({
52+
id: 'text-full',
53+
kind: 'text',
54+
rawValue: '<start.hello>',
55+
searchText: "return '<start.hello>'",
56+
range: { start: 8, end: 21 },
57+
})
58+
const referenceMatch = createMatch({
59+
id: 'workflow-reference',
60+
kind: 'workflow-reference',
61+
rawValue: '<start.hello>',
62+
searchText: 'start.hello',
63+
range: { start: 8, end: 21 },
64+
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
65+
})
66+
67+
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([
68+
referenceMatch,
69+
])
70+
})
71+
72+
it('does not collapse matches from different fields', () => {
73+
const firstMatch = createMatch({
74+
id: 'first',
75+
range: { start: 0, end: 4 },
76+
valuePath: ['first'],
77+
})
78+
const secondMatch = createMatch({
79+
id: 'second',
80+
range: { start: 0, end: 4 },
81+
valuePath: ['second'],
82+
})
83+
84+
expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([
85+
firstMatch,
86+
secondMatch,
87+
])
88+
})
89+
})

apps/sim/lib/workflows/search-replace/resources/resolvers.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
WorkflowSearchMatch,
33
WorkflowSearchReplacementOption,
44
WorkflowSearchResourceMeta,
5+
WorkflowSearchValuePath,
56
} from '@/lib/workflows/search-replace/types'
67
import type { SelectorContext } from '@/hooks/selectors/types'
78

@@ -88,6 +89,74 @@ export function getWorkflowSearchCompatibleResourceMatches(
8889
)
8990
}
9091

92+
function searchValuePathKey(path: WorkflowSearchValuePath): string {
93+
return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/')
94+
}
95+
96+
function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null {
97+
if (!match.range) return null
98+
if (match.target.kind !== 'subblock') return null
99+
return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':')
100+
}
101+
102+
function rangesOverlap(
103+
left: NonNullable<WorkflowSearchMatch['range']>,
104+
right: NonNullable<WorkflowSearchMatch['range']>
105+
): boolean {
106+
return left.start < right.end && right.start < left.end
107+
}
108+
109+
function getRangeLength(match: WorkflowSearchMatch): number {
110+
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
111+
}
112+
113+
function shouldPreferOverlappingMatch(
114+
candidate: WorkflowSearchMatch,
115+
current: WorkflowSearchMatch
116+
): boolean {
117+
const candidateLength = getRangeLength(candidate)
118+
const currentLength = getRangeLength(current)
119+
if (candidateLength !== currentLength) return candidateLength < currentLength
120+
121+
if (candidate.kind !== current.kind) {
122+
if (candidate.kind !== 'text') return true
123+
if (current.kind !== 'text') return false
124+
}
125+
126+
return false
127+
}
128+
129+
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
130+
matches: T[]
131+
): T[] {
132+
const deduped: T[] = []
133+
134+
for (const match of matches) {
135+
const scopeKey = getRangeMatchScopeKey(match)
136+
const matchRange = match.range
137+
const existingIndex =
138+
scopeKey && matchRange
139+
? deduped.findIndex(
140+
(candidate) =>
141+
getRangeMatchScopeKey(candidate) === scopeKey &&
142+
candidate.range &&
143+
rangesOverlap(candidate.range, matchRange)
144+
)
145+
: -1
146+
147+
if (existingIndex === -1) {
148+
deduped.push(match)
149+
continue
150+
}
151+
152+
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
153+
deduped[existingIndex] = match
154+
}
155+
}
156+
157+
return deduped
158+
}
159+
91160
export function workflowSearchMatchMatchesQuery(
92161
match: WorkflowSearchMatch & { displayLabel?: string },
93162
query: string,

0 commit comments

Comments
 (0)