fix(search-replace): don't auto-navigate when content edits invalidate the active match (#4819)

* fix(search-replace): don't auto-navigate when content edits invalidate the active match

* fix(search-replace): clear afterReplaceIndexRef on apply failure and zero matches

* fix(search-replace): remove duplicate setActiveSearchTarget(null) on close

* fix(search-replace): move afterReplaceIndexRef write inside handleApply past the guard

* fix(search-replace): auto-navigate when hydration resolves with no prior active match

* chore(search-replace): remove inline comments

* fix(search-replace): revert !activeMatchId guard that caused immediate re-navigation after deselect
This commit is contained in:
Waleed
2026-05-30 18:58:37 -07:00
committed by GitHub
parent 20a8d85e15
commit 1ae1afbe23
@@ -169,6 +169,9 @@ export function WorkflowSearchReplace() {
setActiveMatchId: state.setActiveMatchId,
}))
)
const prevQueryRef = useRef(query)
const prevIsOpenRef = useRef(false)
const afterReplaceIndexRef = useRef<number | null>(null)
const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen })
useRegisterGlobalCommands([
@@ -248,10 +251,7 @@ export function WorkflowSearchReplace() {
)
useEffect(() => {
if (!isOpen) {
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
return
}
if (!isOpen) return
searchInputRef.current?.focus()
searchInputRef.current?.select()
}, [isOpen])
@@ -311,10 +311,7 @@ export function WorkflowSearchReplace() {
return []
}, [activeMatch, hydratedMatches])
const eligibleMatchIds = useMemo(
() => replaceAllTargetMatches.map((match) => match.id),
[replaceAllTargetMatches]
)
const eligibleMatchIds = replaceAllTargetMatches.map((match) => match.id)
const controlTargetMatches = activeMatch ? [activeMatch] : []
const usesResourceReplacement = controlTargetMatches.some(isConstrainedResourceMatch)
const resourceReplacementContextKey =
@@ -324,23 +321,20 @@ export function WorkflowSearchReplace() {
const replacement = resourceReplacementContextKey
? (resourceReplacementByContext[resourceReplacementContextKey] ?? '')
: textReplacement
const handleReplacementChange = useCallback(
(nextReplacement: string) => {
if (!resourceReplacementContextKey) {
setReplacement(nextReplacement)
return
}
const handleReplacementChange = (nextReplacement: string) => {
if (!resourceReplacementContextKey) {
setReplacement(nextReplacement)
return
}
setResourceReplacementByContext((current) => ({
...current,
[resourceReplacementContextKey]: nextReplacement,
}))
},
[resourceReplacementContextKey, setReplacement]
)
const compatibleResourceOptions = useMemo(
() => getCompatibleResourceReplacementOptions(controlTargetMatches, resourceOptions),
[controlTargetMatches, resourceOptions]
setResourceReplacementByContext((current) => ({
...current,
[resourceReplacementContextKey]: nextReplacement,
}))
}
const compatibleResourceOptions = getCompatibleResourceReplacementOptions(
controlTargetMatches,
resourceOptions
)
const hasReplacement = replacement.trim().length > 0
const activeReplacementIssue = activeMatch
@@ -359,34 +353,51 @@ export function WorkflowSearchReplace() {
})
: 'No replaceable matches.'
const applySubflowUpdate = useCallback(
(update: WorkflowSearchReplaceSubflowUpdate) => {
if (update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations) {
if (typeof update.nextValue !== 'number') return
collaborativeUpdateIterationCount(update.blockId, update.blockType, update.nextValue)
return
}
const applySubflowUpdate = (update: WorkflowSearchReplaceSubflowUpdate) => {
if (update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations) {
if (typeof update.nextValue !== 'number') return
collaborativeUpdateIterationCount(update.blockId, update.blockType, update.nextValue)
return
}
collaborativeUpdateIterationCollection(
update.blockId,
update.blockType,
String(update.nextValue)
)
},
[collaborativeUpdateIterationCollection, collaborativeUpdateIterationCount]
)
collaborativeUpdateIterationCollection(
update.blockId,
update.blockType,
String(update.nextValue)
)
}
useEffect(() => {
if (!isOpen) return
if (!isOpen) {
prevIsOpenRef.current = false
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
return
}
const justOpened = !prevIsOpenRef.current
prevIsOpenRef.current = true
const queryChanged = prevQueryRef.current !== query
prevQueryRef.current = query
if (hydratedMatches.length === 0) {
afterReplaceIndexRef.current = null
if (activeMatchId) setActiveMatchId(null)
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
return
}
if (!activeMatchId || !hydratedMatches.some((match) => match.id === activeMatchId)) {
handleSelectMatch(hydratedMatches[0].id)
const replaceIndex = afterReplaceIndexRef.current
afterReplaceIndexRef.current = null
if (queryChanged || justOpened) {
handleSelectMatch(hydratedMatches[0].id)
} else if (replaceIndex !== null) {
handleSelectMatch(hydratedMatches[Math.min(replaceIndex, hydratedMatches.length - 1)].id)
} else {
setActiveMatchId(null)
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
}
return
}
@@ -401,14 +412,19 @@ export function WorkflowSearchReplace() {
const handleMoveActiveMatch = (delta: number) => {
if (hydratedMatches.length === 0) return
const currentIndex = activeMatchIndex >= 0 ? activeMatchIndex : 0
const nextIndex = (currentIndex + delta + hydratedMatches.length) % hydratedMatches.length
if (activeMatchIndex < 0) {
handleSelectMatch(hydratedMatches[delta > 0 ? 0 : hydratedMatches.length - 1].id)
return
}
const nextIndex = (activeMatchIndex + delta + hydratedMatches.length) % hydratedMatches.length
handleSelectMatch(hydratedMatches[nextIndex].id)
}
const handleApply = (matchIds: string[]) => {
const handleApply = (matchIds: string[], replaceActiveIndex?: number) => {
if (!workflowId || isApplying || searchReadOnly) return
if (replaceActiveIndex !== undefined) afterReplaceIndexRef.current = replaceActiveIndex
setIsApplying(true)
let committed = false
try {
const selectedIds = new Set(matchIds)
@@ -505,14 +521,16 @@ export function WorkflowSearchReplace() {
message: `Replaced ${replacedCount} field${replacedCount === 1 ? '' : 's'}.`,
workflowId,
})
committed = true
} finally {
setIsApplying(false)
if (!committed) afterReplaceIndexRef.current = null
}
}
const handleReplaceActive = () => {
if (!activeMatch) return
handleApply([activeMatch.id])
handleApply([activeMatch.id], activeMatchIndex)
}
const handleReplaceAll = () => {
@@ -522,7 +540,9 @@ export function WorkflowSearchReplace() {
const matchCountLabel =
hydratedMatches.length === 0
? 'No results'
: `${activeMatchIndex >= 0 ? activeMatchIndex + 1 : 1} of ${hydratedMatches.length}`
: activeMatchIndex >= 0
? `${activeMatchIndex + 1} of ${hydratedMatches.length}`
: `0 of ${hydratedMatches.length}`
return (
<div
role='dialog'
@@ -566,7 +586,7 @@ export function WorkflowSearchReplace() {
>
<ChevronRight
className={cn(
'h-[14px] w-[14px] text-[var(--text-icon)] transition-transform',
'size-[14px] text-[var(--text-icon)] transition-transform',
isReplaceExpanded && 'rotate-90'
)}
/>