improvement(search-replace): dedupe double indexed segments (#4517)

* improvement(search-replace): dedupe double indexed segments

* cleanup error message

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-05-08 12:21:52 -07:00
committed by GitHub
parent 9e9ddaa1aa
commit 29affdac58
4 changed files with 204 additions and 5 deletions
@@ -126,10 +126,6 @@ export const PATCH = withRouteHandler(
}
}
if (workflowId && workflowId !== existingChat[0].workflowId) {
return createErrorResponse('Changing a chat deployment workflow is not supported', 400)
}
let encryptedPassword
if (password) {
@@ -9,6 +9,7 @@ import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
import {
dedupeOverlappingWorkflowSearchMatches,
getCompatibleResourceReplacementOptions,
getWorkflowSearchCompatibleResourceMatches,
getWorkflowSearchMatchResourceGroupKey,
@@ -197,7 +198,10 @@ export function WorkflowSearchReplace() {
})
const hydratedMatches = useMemo(
() => allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)),
() =>
dedupeOverlappingWorkflowSearchMatches(
allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query))
),
[allHydratedMatches, query]
)
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { dedupeOverlappingWorkflowSearchMatches } from '@/lib/workflows/search-replace/resources/resolvers'
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
return {
id: 'match',
blockId: 'block-1',
blockName: 'Block',
blockType: 'function',
subBlockId: 'code',
canonicalSubBlockId: 'code',
subBlockType: 'code',
valuePath: [],
target: { kind: 'subblock' },
kind: 'text',
rawValue: '',
searchText: '',
editable: true,
navigable: true,
protected: false,
...overrides,
}
}
describe('dedupeOverlappingWorkflowSearchMatches', () => {
it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => {
const textMatch = createMatch({
id: 'text-partial',
kind: 'text',
rawValue: '<start.h',
searchText: "return '<start.hello>'",
range: { start: 8, end: 16 },
})
const referenceMatch = createMatch({
id: 'workflow-reference',
kind: 'workflow-reference',
rawValue: '<start.hello>',
searchText: 'start.hello',
range: { start: 8, end: 21 },
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
})
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch])
})
it('keeps the inline reference when it covers the same span as a text hit', () => {
const textMatch = createMatch({
id: 'text-full',
kind: 'text',
rawValue: '<start.hello>',
searchText: "return '<start.hello>'",
range: { start: 8, end: 21 },
})
const referenceMatch = createMatch({
id: 'workflow-reference',
kind: 'workflow-reference',
rawValue: '<start.hello>',
searchText: 'start.hello',
range: { start: 8, end: 21 },
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
})
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([
referenceMatch,
])
})
it('uses kind priority rather than iteration order for equal-span non-text matches', () => {
const workflowReferenceMatch = createMatch({
id: 'workflow-reference',
kind: 'workflow-reference',
rawValue: '{{API_KEY}}',
searchText: 'API_KEY',
range: { start: 0, end: 11 },
resource: { kind: 'workflow-reference', token: '{{API_KEY}}', key: 'API_KEY' },
})
const environmentMatch = createMatch({
id: 'environment',
kind: 'environment',
rawValue: '{{API_KEY}}',
searchText: 'API_KEY',
range: { start: 0, end: 11 },
resource: { kind: 'environment', token: '{{API_KEY}}', key: 'API_KEY' },
})
expect(
dedupeOverlappingWorkflowSearchMatches([workflowReferenceMatch, environmentMatch])
).toEqual([workflowReferenceMatch])
expect(
dedupeOverlappingWorkflowSearchMatches([environmentMatch, workflowReferenceMatch])
).toEqual([workflowReferenceMatch])
})
it('does not collapse matches from different fields', () => {
const firstMatch = createMatch({
id: 'first',
range: { start: 0, end: 4 },
valuePath: ['first'],
})
const secondMatch = createMatch({
id: 'second',
range: { start: 0, end: 4 },
valuePath: ['second'],
})
expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([
firstMatch,
secondMatch,
])
})
})
@@ -1,10 +1,27 @@
import type {
WorkflowSearchMatch,
WorkflowSearchMatchKind,
WorkflowSearchReplacementOption,
WorkflowSearchResourceMeta,
WorkflowSearchValuePath,
} from '@/lib/workflows/search-replace/types'
import type { SelectorContext } from '@/hooks/selectors/types'
const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
text: 0,
environment: 1,
'workflow-reference': 2,
'oauth-credential': 3,
'knowledge-base': 3,
'knowledge-document': 3,
workflow: 3,
'mcp-server': 3,
'mcp-tool': 3,
table: 3,
file: 3,
'selector-resource': 3,
}
export function stableStringifyWorkflowSearchValue(value: unknown): string {
if (!value || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) {
@@ -88,6 +105,73 @@ export function getWorkflowSearchCompatibleResourceMatches(
)
}
function searchValuePathKey(path: WorkflowSearchValuePath): string {
return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/')
}
function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null {
if (!match.range) return null
if (match.target.kind !== 'subblock') return null
return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':')
}
function rangesOverlap(
left: NonNullable<WorkflowSearchMatch['range']>,
right: NonNullable<WorkflowSearchMatch['range']>
): boolean {
return left.start < right.end && right.start < left.end
}
function getRangeLength(match: WorkflowSearchMatch): number {
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
}
function shouldPreferOverlappingMatch(
candidate: WorkflowSearchMatch,
current: WorkflowSearchMatch
): boolean {
const candidateLength = getRangeLength(candidate)
const currentLength = getRangeLength(current)
if (candidateLength !== currentLength) return candidateLength < currentLength
const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind]
const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind]
if (candidatePriority !== currentPriority) return candidatePriority > currentPriority
return false
}
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
matches: T[]
): T[] {
const deduped: T[] = []
for (const match of matches) {
const scopeKey = getRangeMatchScopeKey(match)
const matchRange = match.range
const existingIndex =
scopeKey && matchRange
? deduped.findIndex(
(candidate) =>
getRangeMatchScopeKey(candidate) === scopeKey &&
candidate.range &&
rangesOverlap(candidate.range, matchRange)
)
: -1
if (existingIndex === -1) {
deduped.push(match)
continue
}
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
deduped[existingIndex] = match
}
}
return deduped
}
export function workflowSearchMatchMatchesQuery(
match: WorkflowSearchMatch & { displayLabel?: string },
query: string,