mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
refactor(search): one definition of what a search occurrence is (#6905)
Follow-up to #6901, closing two places where the workflow search index and the Note card that mirrors it could drift apart. Neither is a live bug; both are the shape that produced one — the card silently disagreeing with the panel about which hit is which, counted in one place and painted in another. THE SCAN. #6901 shared `foldSearchWhitespace` but left the scan around it duplicated: normalize, then non-overlapping `indexOf` stepping by `max(len, 1)`, written out once in the indexer and once in the renderer package. They agree today. They would stop agreeing the moment either grew whole-word matching, diacritic folding, or a regex mode, and the failure is silent. Both now call one `forEachSearchOccurrence` in `@sim/utils/string` — the only place either package can share, since the card renders from a package that cannot import from `apps/*`. THE DECLARATION. The indexer projects markdown escapes only for a field declaring `searchTextFormat: 'markdown'`; the card projects unconditionally, because it cannot read the block registry. Dropping that one line from the Note config would leave them disagreeing with nothing to catch it, so a test now pins it and explains why. Net negative in lines: this deletes a duplicated loop rather than adding a layer. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e111f615c
commit
85290693c4
@@ -12,6 +12,7 @@ import {
|
||||
SEARCH_REPLACE_BLOCK_CONFIGS,
|
||||
} from '@/lib/workflows/search-replace/search-replace.fixtures'
|
||||
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
|
||||
import { NoteBlock } from '@/blocks/blocks/note'
|
||||
|
||||
/**
|
||||
* Uses the real tool registry. Nothing here imports it directly — the dependency
|
||||
@@ -167,6 +168,19 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
|
||||
})
|
||||
|
||||
describe('the Note body declares the markdown format the card assumes', () => {
|
||||
/*
|
||||
* The canvas card projects markdown escapes unconditionally — it renders from a package that
|
||||
* cannot read the block registry. The indexer projects only when the field says so. Dropping
|
||||
* the declaration would leave the two disagreeing about what an occurrence is, and the failure
|
||||
* is silent: the panel counts a hit the card marks somewhere else.
|
||||
*/
|
||||
it('keeps searchTextFormat on the Note content field', () => {
|
||||
const content = NoteBlock.subBlocks.find((subBlock) => subBlock.id === 'content')
|
||||
expect(content?.searchTextFormat).toBe('markdown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a markdown field is searched as it renders', () => {
|
||||
/*
|
||||
* The rich-text editor backslash-escapes every markdown-significant character in prose, so a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
|
||||
import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/utils/string'
|
||||
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
|
||||
import type { SubBlockType } from '@sim/workflow-types/blocks'
|
||||
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
|
||||
@@ -57,16 +57,6 @@ import {
|
||||
type ToolParameterConfig,
|
||||
} from '@/tools/params'
|
||||
|
||||
/**
|
||||
* Whitespace is folded before comparison (see {@link foldSearchWhitespace}):
|
||||
* the fold is one-to-one, so ranges found in the normalized string index the
|
||||
* original text correctly.
|
||||
*/
|
||||
function normalizeForSearch(value: string, caseSensitive: boolean): string {
|
||||
const folded = foldSearchWhitespace(value)
|
||||
return caseSensitive ? folded : folded.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranges of `query` in `value`, always in `value`'s own coordinates.
|
||||
*
|
||||
@@ -83,23 +73,21 @@ function findTextRanges(
|
||||
caseSensitive: boolean,
|
||||
searchTextFormat?: SubBlockConfig['searchTextFormat']
|
||||
) {
|
||||
if (!query) return []
|
||||
|
||||
const projection = searchTextFormat === 'markdown' ? projectEscapedMarkdownForSearch(value) : null
|
||||
const source = normalizeForSearch(projection ? projection.text : value, caseSensitive)
|
||||
const target = normalizeForSearch(query, caseSensitive)
|
||||
const ranges: Array<{ start: number; end: number }> = []
|
||||
|
||||
let index = source.indexOf(target)
|
||||
while (index !== -1) {
|
||||
const end = index + target.length
|
||||
ranges.push(
|
||||
projection
|
||||
? { start: projection.starts[index], end: projection.starts[end] }
|
||||
: { start: index, end }
|
||||
)
|
||||
index = source.indexOf(target, index + Math.max(target.length, 1))
|
||||
}
|
||||
forEachSearchOccurrence(
|
||||
projection ? projection.text : value,
|
||||
query,
|
||||
(start, end) => {
|
||||
ranges.push(
|
||||
projection
|
||||
? { start: projection.starts[start], end: projection.starts[end] }
|
||||
: { start, end }
|
||||
)
|
||||
},
|
||||
caseSensitive
|
||||
)
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user