mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(search): answer a Note match on the canvas card (#6901)
* fix(search): answer a Note match on the canvas card A workflow search match inside a Note counted towards the result total and then highlighted nowhere: the editor panel renders nothing for a Note, and `clearCurrentBlock` — which the panel calls to refuse one — also cleared the shared `activeSearchTarget`, destroying the very target the card was about to paint. Searching a 15k-character note reported "1 of 6" and moved nothing. The card's read view is now the surface that answers: - A rehype plugin marks every rendered occurrence; the current one is picked by an ordinal counted with the same scan the indexer uses, and travels by context so cycling matches does not re-parse the document. - `<Streamdown>` is keyed on the query. Its memo comparator ignores `rehypePlugins`/`components`, so a plugin change alone cannot re-render it — marks appeared only when something else remounted the card, and then outlived the query that produced them. - The canvas selects, centres and expands the Note, because a compact card resets its scroll region to the top and cannot hold a position deep in its own body. Scrolling to the mark is `scrollTop` arithmetic, never `scrollIntoView`, which would drag ReactFlow's transformed viewport off-frame. - Title matches mark the name too. `activeSearchTarget` is re-published with a fresh identity on most of the search panel's renders, so subscribers take primitives. Holding the object in `WorkflowContent` — the panel's own ancestor — closed an unbounded update loop. Separately, the serializer escaped every underscore, writing `SB\_ACTION\_ROUTER\_SECRET` into the document. CommonMark's intraword rule means that backslash carries no meaning, and search matches the stored markdown, so it made anything with an underscore unfindable in a note that plainly showed it. Dropped outside code regions, where the serializer emits verbatim and a backslash is the author's own character. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search): honour fences and folded whitespace in Note highlighting Two review findings, both real. The intraword-underscore cleanup guarded code with a pattern that recognised only the shortest delimiter forms — a bare ``` pair and a single-backtick span. A ````-fenced block, a tilde fence, or a ``multi-backtick`` span ended the region early and handed the rest of the author's code to the rewrite, turning `a\_b` into `a_b` inside their code sample. Fenced blocks are now walked a line at a time, tracking the opening delimiter exactly the way stripEmptyListItemLines already does (three or more, closed only by a run at least as long), and the inline branch matches a backtick RUN closed by one of equal length. The note scanner claimed to be the same scan as the indexer's `findTextRanges` but did not fold whitespace, which staging added since this branch was written. The indexer folds every `\s` to a space, so a phrase matches across a soft line break — which `remark-breaks` renders as a `<br>`, splitting the phrase over two text nodes that a per-node scan could never see. The hit counted in the panel and highlighted nowhere, the exact bug this branch exists to fix. The plugin now scans runs of continuously-readable text rather than single nodes, so a match spanning an inline boundary (a soft break, a bold word) is wrapped as several marks sharing one ordinal. Runs end at any non-inline element, so two paragraphs are never joined into a phrase the reader cannot see. `foldSearchWhitespace` moved to `@sim/utils/string`: the canvas card renders from a package, which cannot import from `apps/*`, and two copies of that rule silently disagreeing is precisely what produced the second finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(markdown): close a fence only on a bare delimiter run A closing fence carries nothing but its delimiter run; a line that merely starts with one is content. The guard matched the prefix alone, so an interior line like ` ````example ` inside a same-length fence ended the block, and every cleanup below then processed the author's remaining code as prose — dropping the backslashes from their `a\_b`. Both fence walks in this file shared that flaw, so both now go through one `closesFence`, which requires the run to be followed by nothing but whitespace. Strictly more conservative: a fence stays open longer, so more content is left verbatim. Scope, stated plainly: the serializer always opens a block with one more delimiter than the longest run inside it, so its own output cannot reach this shape today, and `postProcessSerializedMarkdown` only ever sees serializer output. This is a correctness fix that removes an unstated coupling to that choice, not a live corruption path. The tests therefore exercise `postProcessSerializedMarkdown` directly — a round-trip test of the same input would pass either way, which is exactly the vacuous check worth avoiding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(markdown): skip quoted fences, and stop joining runs across inline tags Two review findings, both real, both the same shape: a rule that looked at the rendered form and forgot what the source actually says. QUOTED FENCES. The fence walk only recognised a bare delimiter run, but the serializer writes a fence inside a blockquote or a `[!NOTE]` callout with a `>` on every line. Code state was therefore never entered there and the block's interior was cleaned up as prose: `> x = a\_b` round-tripped to `> x = a_b`, losing the author's backslash. Unlike the fence-length cases this one is reachable today — verified against the real serializer before and after. Both fence walks now unquote the line first. INLINE JOINS. Runs concatenated the visible text of every inline tag, so `a<strong>b</strong>c` read as `abc` — a hit that cannot exist in the markdown the indexer scans, where `**` sits between the words. That is worse than a spurious mark: `occurrenceIndex` counts SOURCE occurrences, so a fabricated hit earlier in the document steals the current ordinal and paints the mark on text the search never matched. Only `<br>` continues a run now, because it alone stands for a character the source really has (a `\n`, folded to a space). Everything else stands for syntax the render drops. Nothing real is lost: a match spanning `a**b**c` would have to contain the asterisks to exist at all, and a match wholly inside an element is still found — the element simply starts its own run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search): match a Note body as it renders, instead of rewriting the file Replaces the serializer change with one that writes nothing. The editor backslash-escapes every markdown-significant character in prose, so a Note the reader sees as `{{TE_SERET}}` is stored as `{{TE\_SERET}}` and search — which matches the stored value — could not find it. The previous approach undid that escape in `postProcessSerializedMarkdown`, which meant re-deriving markdown structure from the serialized string with regexes so it knew what was code. That is a losing game: three review rounds, each finding another construct it did not model (longer fences, then delimiter-prefixed lines, then quoted fences), and each miss REWROTE somebody's code. `markdown-fidelity.ts` is back to staging, byte for byte. The escape is now undone on the matching side only. A field declares `searchTextFormat: 'markdown'` (the Note body is the only one), and the indexer matches it against `projectEscapedMarkdownForSearch(value)` — a total, structure-free function that returns the rendered text plus an index back into the source. Ranges stay in source coordinates, so replace still rewrites the whole `\_` and never strands a backslash. The asymmetry is the whole point: a matcher that de-escapes something a fence would have kept literal changes only which text highlights, and no caller writes it back. A rewriter making the identical mistake corrupts the file. So there is nothing here that needs to know about fences at all. Two consequences worth having: existing notes are searchable immediately rather than after their next edit, and no stored byte changes, so no document the editor has ever written can be affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a27f376164
commit
2e111f615c
+146
-4
@@ -1,16 +1,21 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
countNoteSearchOccurrencesBefore,
|
||||
DEFAULT_NOTE_COLOR,
|
||||
estimateNoteBlockHeight,
|
||||
forEachNoteSourceOccurrence,
|
||||
getNoteStringValue,
|
||||
isNoteColor,
|
||||
NoteBlockView,
|
||||
type NoteColor,
|
||||
type NoteContentEditorProps,
|
||||
type NoteSearchHighlight,
|
||||
type NoteSearchRange,
|
||||
} from '@sim/workflow-renderer'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { type NodeProps, useReactFlow } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { appendNoteImageMarkdown } from '@/lib/workflows/notes/add-image'
|
||||
import {
|
||||
NOTE_ADD_IMAGE_EVENT,
|
||||
@@ -26,7 +31,7 @@ import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]
|
||||
import { isBlockProtected } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useIsCurrentWorkflowExecuting } from '@/stores/execution'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -48,6 +53,9 @@ const NoteMarkdownEditor = dynamic(
|
||||
|
||||
const NOTE_EXPAND_FOCUS_DURATION_MS = 300
|
||||
|
||||
/** The markdown body's sub-block id, as declared by the Note block config. */
|
||||
const NOTE_CONTENT_SUBBLOCK_ID = 'content'
|
||||
|
||||
function renderNoteContentEditor(props: NoteContentEditorProps) {
|
||||
return <NoteMarkdownEditor {...props} />
|
||||
}
|
||||
@@ -106,7 +114,92 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
useCallback((state) => isBlockProtected(id, state.blocks), [id])
|
||||
)
|
||||
const clearCurrentBlock = usePanelEditorStore((state) => state.clearCurrentBlock)
|
||||
/* Flattened to primitives under a shallow compare, never held as the target
|
||||
object: the search panel re-publishes an equal target on most of its own
|
||||
renders, and a card that subscribes to the object rebuilds its highlight on
|
||||
every one of them. */
|
||||
const searchTarget = usePanelEditorSearchStore(
|
||||
useShallow((state) => {
|
||||
const target = state.activeSearchTarget
|
||||
return {
|
||||
blockId: target?.blockId ?? null,
|
||||
subBlockId: target?.subBlockId ?? null,
|
||||
targetKind: target?.targetKind ?? null,
|
||||
query: target?.query ?? null,
|
||||
rawValue: target?.rawValue ?? null,
|
||||
rangeStart: target?.range?.start ?? null,
|
||||
rangeEnd: target?.range?.end ?? null,
|
||||
}
|
||||
})
|
||||
)
|
||||
const canEditNote = canEditWorkflow && !data.isPreview && !isProtected
|
||||
|
||||
/** Whether the active search match belongs to this card at all. */
|
||||
const isSearchTargetBlock =
|
||||
!data.isPreview &&
|
||||
!data.isEmbedded &&
|
||||
searchTarget.blockId === id &&
|
||||
Boolean(searchTarget.query)
|
||||
|
||||
/**
|
||||
* The workflow search match this card should paint in its body.
|
||||
*
|
||||
* The panel editor renders nothing for a note, so the card is the only
|
||||
* surface that can answer a search — without this a note match counts towards
|
||||
* "1 of 6" and then highlights nowhere.
|
||||
*
|
||||
* The stored range is re-checked against the live content before it is
|
||||
* trusted: the index runs over a snapshot, and a collaborator's edit between
|
||||
* indexing and painting would shift the ordinal onto the wrong words. When it
|
||||
* no longer holds — or the match never carried one — this falls back to the
|
||||
* first rendered occurrence, which is the same fallback the editor panel
|
||||
* makes for a label whose stored range does not fit.
|
||||
*/
|
||||
const searchHighlight = useMemo<NoteSearchHighlight | null>(() => {
|
||||
if (!isSearchTargetBlock) return null
|
||||
if (searchTarget.targetKind !== 'subblock') return null
|
||||
if (searchTarget.subBlockId !== NOTE_CONTENT_SUBBLOCK_ID) return null
|
||||
|
||||
const { query, rawValue, rangeStart, rangeEnd } = searchTarget
|
||||
if (!query) return null
|
||||
|
||||
const rangeHolds =
|
||||
rangeStart !== null && rangeEnd !== null && content.slice(rangeStart, rangeEnd) === rawValue
|
||||
if (rangeHolds) {
|
||||
return {
|
||||
query,
|
||||
occurrenceIndex: countNoteSearchOccurrencesBefore(content, query, rangeStart),
|
||||
}
|
||||
}
|
||||
|
||||
/* Asked through the same scan that will do the marking, rather than a local
|
||||
`includes`: a bare comparison skips the whitespace fold, so a query the
|
||||
indexer matched across a newline or a non-breaking space would read as
|
||||
absent here and the card would paint nothing while the panel counted it. */
|
||||
let occurs = false
|
||||
forEachNoteSourceOccurrence(content, query, () => {
|
||||
occurs = true
|
||||
})
|
||||
return occurs ? { query, occurrenceIndex: 0 } : null
|
||||
}, [content, isSearchTargetBlock, searchTarget])
|
||||
|
||||
/**
|
||||
* The match to paint in the title, for a search that hit the note's name.
|
||||
*
|
||||
* A name match carries an exact range over `block.name`, so unlike the body
|
||||
* there is no occurrence to reconstruct — it is used directly, once it still
|
||||
* describes the live name.
|
||||
*/
|
||||
const nameSearchRange = useMemo<NoteSearchRange | null>(() => {
|
||||
if (!isSearchTargetBlock) return null
|
||||
if (searchTarget.targetKind !== 'block-name') return null
|
||||
|
||||
const { rawValue, rangeStart, rangeEnd } = searchTarget
|
||||
if (rangeStart === null || rangeEnd === null) return null
|
||||
if ((name ?? '').slice(rangeStart, rangeEnd) !== rawValue) return null
|
||||
|
||||
return { start: rangeStart, end: rangeEnd }
|
||||
}, [isSearchTargetBlock, name, searchTarget])
|
||||
const uploadNoteImage = useNoteImageUpload()
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const [blockHeight, setBlockHeight] = useState(() => estimateNoteBlockHeight(content))
|
||||
@@ -132,6 +225,48 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
[]
|
||||
)
|
||||
|
||||
const isExpandedRef = useRef(isExpanded)
|
||||
useEffect(() => {
|
||||
isExpandedRef.current = isExpanded
|
||||
}, [isExpanded])
|
||||
|
||||
/**
|
||||
* Opens the card while it holds the current search match, and closes it again
|
||||
* when the match moves on.
|
||||
*
|
||||
* A compact note shows a few lines of what is often a long document, so a
|
||||
* match found deep inside it lands in a body the user cannot read. Expanding
|
||||
* is the same gesture a click makes, and it gives the mark somewhere to be.
|
||||
*
|
||||
* Two things this deliberately does not do. It does not call
|
||||
* {@link handleExpandedChange}, whose own `setCenter` would race the camera
|
||||
* the canvas already moved for this match. And it only closes a card it
|
||||
* opened — a note the user expanded by hand, or collapsed by hand while the
|
||||
* match still points here, is left exactly as they left it.
|
||||
*/
|
||||
const searchExpandedRef = useRef(false)
|
||||
const hasSearchMatch = searchHighlight !== null || nameSearchRange !== null
|
||||
useEffect(() => {
|
||||
/* Losing edit rights already force-collapses the card during render. Drop the latch with it,
|
||||
or regaining them would hit the early return below and leave a deep match clipped in the
|
||||
compact body until the active match moved. */
|
||||
if (!canEditNote) {
|
||||
searchExpandedRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (hasSearchMatch) {
|
||||
if (searchExpandedRef.current || isExpandedRef.current) return
|
||||
searchExpandedRef.current = true
|
||||
setIsExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!searchExpandedRef.current) return
|
||||
searchExpandedRef.current = false
|
||||
setIsExpanded(false)
|
||||
}, [canEditNote, hasSearchMatch])
|
||||
|
||||
const handleNameChange = (nextName: string) => {
|
||||
if (!canEditNote) return false
|
||||
return collaborativeUpdateBlockName(id, nextName).success
|
||||
@@ -139,7 +274,7 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
|
||||
const handleContentChange = (nextContent: string) => {
|
||||
if (!canEditNote) return
|
||||
collaborativeSetSubblockValue(id, 'content', nextContent)
|
||||
collaborativeSetSubblockValue(id, NOTE_CONTENT_SUBBLOCK_ID, nextContent)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,9 +310,14 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
if (!image) continue
|
||||
/* Re-read per image: each append has to build on the previous one, and on
|
||||
anything a collaborator wrote while the upload was in flight. */
|
||||
const current = getNoteStringValue(useSubBlockStore.getState().getValue(id, 'content')) ?? ''
|
||||
const current =
|
||||
getNoteStringValue(useSubBlockStore.getState().getValue(id, NOTE_CONTENT_SUBBLOCK_ID)) ?? ''
|
||||
setExternalContentWrites((count) => count + 1)
|
||||
collaborativeSetSubblockValue(id, 'content', appendNoteImageMarkdown(current, image))
|
||||
collaborativeSetSubblockValue(
|
||||
id,
|
||||
NOTE_CONTENT_SUBBLOCK_ID,
|
||||
appendNoteImageMarkdown(current, image)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +432,8 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
onExpandedChange={handleExpandedChange}
|
||||
onImageFilesDrop={(files) => void insertImages(files)}
|
||||
renderContentEditor={renderNoteContentEditor}
|
||||
searchHighlight={searchHighlight}
|
||||
nameSearchRange={nameSearchRange}
|
||||
actionBar={
|
||||
<ActionBar
|
||||
blockId={id}
|
||||
|
||||
@@ -145,7 +145,7 @@ import {
|
||||
} from '@/stores/execution'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import type { PendingConnect } from '@/stores/modals/search/types'
|
||||
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
|
||||
import { usePanelEditorSearchStore, usePanelEditorStore, usePanelStore } from '@/stores/panel'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useVariablesModalStore } from '@/stores/variables/modal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -4608,6 +4608,73 @@ const WorkflowContent = React.memo(
|
||||
return () => window.removeEventListener('keydown', handleArrowNavigation, true)
|
||||
}, [embedded, getNodes, blocks, focusBlockInView])
|
||||
|
||||
/**
|
||||
* Brings a Note holding the current search match onto the canvas.
|
||||
*
|
||||
* Every other block answers a search through the editor panel, which scrolls
|
||||
* the matching field into view for free. A Note renders nothing there — the
|
||||
* card itself is the surface — so the camera has to do that job here, and
|
||||
* the card has to be selected before it will hold a scroll position deep in
|
||||
* its own body rather than snapping back to the top.
|
||||
*
|
||||
* Keyed on the match rather than the block, so cycling between two matches
|
||||
* in one Note re-asserts a camera that is already where it needs to be
|
||||
* (visually inert) instead of stranding the second match off-screen after
|
||||
* the user has panned away. Matches on every other kind of block are marked
|
||||
* handled and otherwise left alone — without that, walking away to a block
|
||||
* match and back to a Note one would read as the same match twice and skip
|
||||
* the camera the second time.
|
||||
*
|
||||
* Also covers a match on the Note's *name*, which the card cannot underline
|
||||
* but which at least lands the user on the right card.
|
||||
*
|
||||
* Subscribed as two ids and NEVER as the target object. The search panel
|
||||
* renders inside this component and re-publishes an equal target on most of
|
||||
* its own renders (its hydration hooks hand back fresh arrays), so holding
|
||||
* the object here re-renders the panel, whose effect re-publishes, which
|
||||
* re-renders it again — an unbounded update loop the moment a search opens.
|
||||
* Two string selectors are compared by value, so a re-publish of the same
|
||||
* match is inert.
|
||||
*/
|
||||
const searchMatchId = usePanelEditorSearchStore(
|
||||
(state) => state.activeSearchTarget?.matchId ?? null
|
||||
)
|
||||
const searchMatchBlockId = usePanelEditorSearchStore(
|
||||
(state) => state.activeSearchTarget?.blockId ?? null
|
||||
)
|
||||
const focusedSearchMatchIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (embedded) return
|
||||
if (!searchMatchId || !searchMatchBlockId) {
|
||||
focusedSearchMatchIdRef.current = null
|
||||
return
|
||||
}
|
||||
if (searchMatchId === focusedSearchMatchIdRef.current) return
|
||||
|
||||
if (blocks[searchMatchBlockId]?.type !== 'note') {
|
||||
focusedSearchMatchIdRef.current = searchMatchId
|
||||
return
|
||||
}
|
||||
|
||||
/* Read from `displayNodes` rather than `getNodes()` so a match that
|
||||
arrives before its node has mounted is retried on the commit that
|
||||
mounts it, instead of being dropped. */
|
||||
const node = displayNodes.find((candidate) => candidate.id === searchMatchBlockId)
|
||||
if (!node) return
|
||||
|
||||
focusedSearchMatchIdRef.current = searchMatchId
|
||||
setDisplayNodes((currentNodes) =>
|
||||
resolveSelectionConflicts(
|
||||
currentNodes.map((currentNode) => ({
|
||||
...currentNode,
|
||||
selected: currentNode.id === node.id,
|
||||
})),
|
||||
blocks
|
||||
)
|
||||
)
|
||||
focusBlockInView(node)
|
||||
}, [blocks, displayNodes, embedded, focusBlockInView, searchMatchBlockId, searchMatchId])
|
||||
|
||||
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
||||
const onEdgeClick = useCallback(
|
||||
(event: React.MouseEvent, edge: any) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const NoteBlock: BlockConfig = {
|
||||
{
|
||||
id: 'content',
|
||||
type: 'long-input',
|
||||
searchTextFormat: 'markdown',
|
||||
rows: 8,
|
||||
placeholder: 'Add context or instructions for collaborators...',
|
||||
description: 'Write your note using Markdown. YouTube links will display as embedded videos.',
|
||||
|
||||
@@ -269,6 +269,19 @@ export interface SubBlockConfig {
|
||||
type: SubBlockType
|
||||
mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is the advanced side of a trigger field — either a canonical pair member or a standalone field shown under the block-level advanced toggle
|
||||
canonicalParamId?: string
|
||||
/**
|
||||
* Declares that the stored value is markdown, so workflow search matches it
|
||||
* against the text it RENDERS as rather than its source.
|
||||
*
|
||||
* The rich-text editor backslash-escapes every markdown-significant character
|
||||
* in prose, so a Note body the reader sees as `SB_ACTION` is stored as
|
||||
* `SB\_ACTION` and would otherwise be unfindable by what is on screen. Ranges
|
||||
* stay in source coordinates, so replace still rewrites the escaped span.
|
||||
*
|
||||
* Omit for every ordinary field: a code or plain-text value is searched as
|
||||
* stored, where a backslash is the author's own character.
|
||||
*/
|
||||
searchTextFormat?: 'markdown'
|
||||
/** Controls parameter visibility in agent/tool-input context */
|
||||
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
|
||||
/**
|
||||
|
||||
@@ -167,6 +167,79 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
|
||||
})
|
||||
|
||||
describe('a markdown field is searched as it renders', () => {
|
||||
/*
|
||||
* The rich-text editor backslash-escapes every markdown-significant character in prose, so a
|
||||
* Note the reader sees as `{{TE_SERET}}` is stored as `{{TE\_SERET}}`. Searching what is on
|
||||
* screen has to see through that, and the range has to keep spanning the escaped source so
|
||||
* replace rewrites the whole `\_` instead of stranding the backslash.
|
||||
*/
|
||||
const NOTE_CONFIGS = {
|
||||
note: { subBlocks: [{ id: 'content', type: 'long-input', searchTextFormat: 'markdown' }] },
|
||||
function: { subBlocks: [{ id: 'code', type: 'code' }] },
|
||||
} as unknown as typeof SEARCH_REPLACE_BLOCK_CONFIGS
|
||||
|
||||
function workflowWith(noteContent: string, code: string) {
|
||||
return {
|
||||
blocks: {
|
||||
'note-1': {
|
||||
id: 'note-1',
|
||||
type: 'note',
|
||||
name: 'Note',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
subBlocks: { content: { id: 'content', type: 'long-input', value: noteContent } },
|
||||
outputs: {},
|
||||
},
|
||||
'fn-1': {
|
||||
id: 'fn-1',
|
||||
type: 'function',
|
||||
name: 'Fn',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
subBlocks: { code: { id: 'code', type: 'code', value: code } },
|
||||
outputs: {},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof indexWorkflowSearchMatches>[0]['workflow']
|
||||
}
|
||||
|
||||
it('finds an escaped underscore by what the reader sees', () => {
|
||||
const matches = indexWorkflowSearchMatches({
|
||||
workflow: workflowWith('{{TE\\_SERET}}', ''),
|
||||
query: '{{TE_',
|
||||
mode: 'text',
|
||||
blockConfigs: NOTE_CONFIGS,
|
||||
})
|
||||
expect(matches.map((match) => match.blockId)).toContain('note-1')
|
||||
})
|
||||
|
||||
it('keeps the range over the escape, so replace cannot strand a backslash', () => {
|
||||
const content = 'uses SB\\_ACTION here'
|
||||
const [match] = indexWorkflowSearchMatches({
|
||||
workflow: workflowWith(content, ''),
|
||||
query: 'SB_ACTION',
|
||||
mode: 'text',
|
||||
blockConfigs: NOTE_CONFIGS,
|
||||
})
|
||||
expect(content.slice(match.range!.start, match.range!.end)).toBe('SB\\_ACTION')
|
||||
expect(match.rawValue).toBe('SB\\_ACTION')
|
||||
})
|
||||
|
||||
/* A code field stores what the author typed: a backslash there is theirs, not an escape. */
|
||||
it('leaves a field that is not markdown searched as stored', () => {
|
||||
const matches = indexWorkflowSearchMatches({
|
||||
workflow: workflowWith('', 'const s = "a\\_b"'),
|
||||
query: 'a_b',
|
||||
mode: 'text',
|
||||
blockConfigs: NOTE_CONFIGS,
|
||||
})
|
||||
expect(matches.map((match) => match.blockId)).not.toContain('fn-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('block references search under the name the canvas shows', () => {
|
||||
/**
|
||||
* The panel's own pipeline: index everything, then keep what the query
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { foldSearchWhitespace, 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'
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
} from '@/lib/workflows/search-replace/json-value-fields'
|
||||
import {
|
||||
buildBlockNamesByReferencePrefix,
|
||||
foldSearchWhitespace,
|
||||
getResourceKindForSubBlock,
|
||||
matchesSearchText,
|
||||
parseInlineReferences,
|
||||
@@ -67,15 +67,37 @@ function normalizeForSearch(value: string, caseSensitive: boolean): string {
|
||||
return caseSensitive ? folded : folded.toLowerCase()
|
||||
}
|
||||
|
||||
function findTextRanges(value: string, query: string, caseSensitive: boolean) {
|
||||
/**
|
||||
* Ranges of `query` in `value`, always in `value`'s own coordinates.
|
||||
*
|
||||
* A field declaring `searchTextFormat: 'markdown'` is matched against the text
|
||||
* it RENDERS as: the rich-text editor backslash-escapes every
|
||||
* markdown-significant character in prose, so a Note body reading `SB_ACTION`
|
||||
* on screen is stored as `SB\_ACTION`. The escape is undone only to match — the
|
||||
* returned range still spans the escaped source, so replace rewrites the whole
|
||||
* `\_` and never strands a backslash.
|
||||
*/
|
||||
function findTextRanges(
|
||||
value: string,
|
||||
query: string,
|
||||
caseSensitive: boolean,
|
||||
searchTextFormat?: SubBlockConfig['searchTextFormat']
|
||||
) {
|
||||
if (!query) return []
|
||||
const source = normalizeForSearch(value, caseSensitive)
|
||||
|
||||
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) {
|
||||
ranges.push({ start: index, end: index + target.length })
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -578,6 +600,8 @@ interface AddTextMatchesOptions {
|
||||
protectedByLock: boolean
|
||||
isSnapshotView: boolean
|
||||
readonlyReason?: string
|
||||
/** Declared by the field's config; see {@link findTextRanges}. */
|
||||
searchTextFormat?: SubBlockConfig['searchTextFormat']
|
||||
}
|
||||
|
||||
function getReadonlyReason({
|
||||
@@ -610,8 +634,9 @@ function addTextMatches({
|
||||
protectedByLock,
|
||||
isSnapshotView,
|
||||
readonlyReason,
|
||||
searchTextFormat,
|
||||
}: AddTextMatchesOptions) {
|
||||
const ranges = query ? findTextRanges(value, query, caseSensitive) : []
|
||||
const ranges = query ? findTextRanges(value, query, caseSensitive, searchTextFormat) : []
|
||||
ranges.forEach((range, occurrenceIndex) => {
|
||||
matches.push({
|
||||
id: createMatchId([
|
||||
@@ -1474,6 +1499,7 @@ export function indexWorkflowSearchMatches(
|
||||
target: { kind: 'subblock' },
|
||||
query,
|
||||
caseSensitive,
|
||||
searchTextFormat: subBlockConfig?.searchTextFormat,
|
||||
editable: leafEditable,
|
||||
protectedByLock,
|
||||
isSnapshotView,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { foldSearchWhitespace } from '@sim/utils/string'
|
||||
import {
|
||||
getWorkflowSearchSubBlockResourceKind,
|
||||
parseWorkflowSearchSubBlockResources,
|
||||
@@ -140,18 +141,6 @@ export function parseStructuredResourceReferences(
|
||||
return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps every Unicode whitespace character to a plain space, one-to-one.
|
||||
* Agent-authored block names and values routinely carry non-breaking or
|
||||
* narrow spaces that render identically to " " but never equal a typed
|
||||
* space, silently hiding matches. The replacement is length-preserving
|
||||
* (every `\s` character is a single UTF-16 unit), so indexes into the
|
||||
* folded string remain valid ranges into the original.
|
||||
*/
|
||||
export function foldSearchWhitespace(value: string): string {
|
||||
return value.replace(/\s/g, ' ')
|
||||
}
|
||||
|
||||
export function matchesSearchText(
|
||||
candidate: string,
|
||||
query: string | undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { foldSearchWhitespace } from '@/lib/workflows/search-replace/resources/references'
|
||||
import { foldSearchWhitespace } from '@sim/utils/string'
|
||||
import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchMatchKind,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Deselecting a block must not end a workflow search.
|
||||
*
|
||||
* `clearCurrentBlock` used to clear `activeSearchTarget` too, which made a Note
|
||||
* match unrenderable: the editor answers a Note by deselecting it, destroying
|
||||
* the target the Note card was about to paint. The search panel owns that
|
||||
* target's lifetime — it re-asserts it on every match change and clears it on
|
||||
* close — so nothing else may reach in and drop it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
|
||||
import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel/editor/store'
|
||||
|
||||
const NOTE_SEARCH_TARGET: ActiveSearchTarget = {
|
||||
matchId: 'text:note-1:content:0',
|
||||
blockId: 'note-1',
|
||||
subBlockId: 'content',
|
||||
canonicalSubBlockId: 'content',
|
||||
valuePath: [],
|
||||
kind: 'text',
|
||||
targetKind: 'subblock',
|
||||
subBlockType: 'long-input',
|
||||
rawValue: 'SB_ACTION_ROUTER_SECRET',
|
||||
searchText: 'uses SB_ACTION_ROUTER_SECRET here',
|
||||
query: 'SB_ACTION_ROUTER_SECRET',
|
||||
range: { start: 5, end: 28 },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
usePanelEditorSearchStore.setState({ activeSearchTarget: null })
|
||||
usePanelEditorStore.setState({ currentBlockId: null })
|
||||
})
|
||||
|
||||
describe('clearCurrentBlock', () => {
|
||||
it('clears the selected block', () => {
|
||||
usePanelEditorStore.getState().setCurrentBlockId('block-1')
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
expect(usePanelEditorStore.getState().currentBlockId).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the active search target in place', () => {
|
||||
usePanelEditorSearchStore.getState().setActiveSearchTarget(NOTE_SEARCH_TARGET)
|
||||
usePanelEditorStore.getState().setCurrentBlockId('note-1')
|
||||
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
|
||||
expect(usePanelEditorSearchStore.getState().activeSearchTarget).toEqual(NOTE_SEARCH_TARGET)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActiveSearchTarget', () => {
|
||||
it('clears the target when the search panel asks it to', () => {
|
||||
usePanelEditorSearchStore.getState().setActiveSearchTarget(NOTE_SEARCH_TARGET)
|
||||
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
|
||||
expect(usePanelEditorSearchStore.getState().activeSearchTarget).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,15 @@ interface PanelEditorState {
|
||||
currentBlockId: string | null
|
||||
/** Sets the current selected block identifier (use null to clear) */
|
||||
setCurrentBlockId: (blockId: string | null) => void
|
||||
/** Clears the current selection */
|
||||
/**
|
||||
* Clears the current selection.
|
||||
*
|
||||
* Leaves {@link PanelEditorSearchState.activeSearchTarget} alone: the search
|
||||
* panel owns that target's lifetime, and deselecting a block is not the end
|
||||
* of a search. Clearing it here made a Note match impossible to render — the
|
||||
* editor answers a Note by deselecting it, which destroyed the very target
|
||||
* the Note card was about to paint.
|
||||
*/
|
||||
clearCurrentBlock: () => void
|
||||
/** Height of the connections section in pixels */
|
||||
connectionsHeight: number
|
||||
@@ -56,7 +64,16 @@ interface PanelEditorState {
|
||||
}
|
||||
|
||||
interface PanelEditorSearchState {
|
||||
/** Ephemeral workflow search target used for scrolling/highlighting editor fields */
|
||||
/**
|
||||
* Ephemeral workflow search target used for scrolling/highlighting editor fields.
|
||||
*
|
||||
* Re-published with a fresh object identity on most of the search panel's own
|
||||
* renders — its match-hydration hooks hand back new arrays, so the effect that
|
||||
* publishes this re-runs constantly with an equal target. Subscribe to the
|
||||
* fields you need as primitives (or under `useShallow`), never to the object.
|
||||
* Any component that both holds the object and can re-render the search panel
|
||||
* closes an unbounded update loop the moment a search opens.
|
||||
*/
|
||||
activeSearchTarget: ActiveSearchTarget | null
|
||||
/** Sets an active search target to highlight in the editor */
|
||||
setActiveSearchTarget: (target: ActiveSearchTarget | null) => void
|
||||
@@ -95,7 +112,6 @@ export const usePanelEditorStore = create<PanelEditorState>()(
|
||||
},
|
||||
clearCurrentBlock: () => {
|
||||
set({ currentBlockId: null })
|
||||
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
|
||||
},
|
||||
setConnectionsHeight: (height) => {
|
||||
const clampedHeight = Math.max(
|
||||
|
||||
Reference in New Issue
Block a user