From 0722dee1d743fb55277d70d3768fe6fe350dae39 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 20:20:52 -0700 Subject: [PATCH 1/2] feat(files): find in an open markdown document with Cmd/Ctrl+F Adds find-in-document to the rich markdown editor, reusing the shared FindBar. A ProseMirror plugin owns the match set and paints inline decorations, so the search never touches the document, the undo history, or the collaborative Y.Doc, and it re-searches on every document change so a highlight can't go stale. Occurrence semantics come from the shared forEachSearchOccurrence rather than a fourth private definition. That helper normalized case with a plain toLowerCase(), which can grow a string and slide every later index, breaking its documented guarantee that bounds index the caller's own string; it now folds case length-preservingly for all three consumers. The Cmd/Ctrl+F open shortcut was a fourth copy of the same effect. It moves to a shared useFindShortcut beside FindBar, which the Files list now uses too. --- apps/sim/app/f/[token]/public-file-view.tsx | 1 + .../components/find-bar/use-find-shortcut.ts | 46 ++++++ .../[workspaceId]/components/index.ts | 1 + .../components/file-viewer/file-viewer.tsx | 17 +- .../rich-markdown-editor/editor-extensions.ts | 6 +- .../find/find-extension.test.ts | 123 ++++++++++++++ .../find/find-extension.ts | 151 +++++++++++++++++ .../find/find-matches.test.ts | 87 ++++++++++ .../rich-markdown-editor/find/find-matches.ts | 108 ++++++++++++ .../rich-markdown-editor/find/index.ts | 2 + .../find/use-markdown-find.ts | 156 ++++++++++++++++++ .../rich-markdown-editor.css | 13 ++ .../rich-markdown-editor.tsx | 102 ++++++++---- .../workspace/[workspaceId]/files/files.tsx | 27 +-- .../resource-content/resource-content.tsx | 1 + packages/utils/src/string.test.ts | 34 ++++ packages/utils/src/string.ts | 31 +++- 17 files changed, 848 insertions(+), 58 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 8247cfe24d7..5f4cd049cb2 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -114,6 +114,7 @@ export function PublicFileView({ contentSource={source} canEdit={false} readOnly + enableFind /> diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts new file mode 100644 index 00000000000..f0bd842f6c9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts @@ -0,0 +1,46 @@ +'use client' + +import type React from 'react' +import { useEffect } from 'react' + +interface UseFindShortcutOptions { + /** + * Whether this surface currently owns Cmd/Ctrl+F. Every find surface binds its own listener, so + * exactly one owner may be enabled at a time — the surfaces arbitrate by mounting (the Files list + * disables itself while a file is open, and the file editor enables itself only where the document + * is the page), by an embed flag (the table grid), or by DOM containment (the browser session). + * Two enabled owners mounted at once would race, and first-registered would win. + */ + enabled: boolean + /** The find bar's input, focused and selected once the bar opens. */ + inputRef: React.RefObject + onOpen: () => void +} + +/** + * Binds Cmd/Ctrl+F to open a find bar, overriding the browser's own find. + * + * Listens on the document rather than a container so the shortcut answers before anything inside the + * surface has been focused — a file that has only been opened, never clicked into, still responds. + * A press another surface already consumed is left alone (`defaultPrevented`), and any chord with a + * further modifier falls through to the browser, so Cmd+Shift+F and Cmd+Alt+F keep their meanings. + */ +export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOptions): void { + useEffect(() => { + if (!enabled) return + const handleFindShortcut = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== 'f') return + if (event.defaultPrevented) return + event.preventDefault() + onOpen() + // After the open has painted the bar, so there is an input to focus. + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + } + document.addEventListener('keydown', handleFindShortcut) + return () => document.removeEventListener('keydown', handleFindShortcut) + }, [enabled, inputRef, onOpen]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 95adab9b7bb..9791199d107 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -8,6 +8,7 @@ export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' export type { FindBarProps } from './find-bar/find-bar' export { FindBar } from './find-bar/find-bar' +export { useFindShortcut } from './find-bar/use-find-shortcut' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 9b878147309..d41df8897e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -127,6 +127,13 @@ interface FileViewerProps { * untitled, so the caller can name the file after it. Only wired for the editable markdown editor. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** + * Let an open markdown file claim Cmd/Ctrl+F for find-in-document. Set wherever the file is the + * whole pane the user is reading — the Files page, the mothership file view, the public share + * page. Left off for the streaming-file preview, which is a pane beside a conversation that owns + * its own find. See {@link RichMarkdownEditorProps.enableFind}. + */ + enableFind?: boolean } export function FileViewer(props: FileViewerProps) { @@ -165,6 +172,7 @@ function FileViewerContent({ previewContextKey, collaborative, onDeriveTitleFromHeading, + enableFind = false, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -181,7 +189,13 @@ function FileViewerContent({ // the bubble menu, and every other editing affordance. if (isMarkdownFile(file)) { return ( - + ) } return @@ -212,6 +226,7 @@ function FileViewerContent({ previewContextKey={previewContextKey} collaborative={collaborative} onDeriveTitleFromHeading={onDeriveTitleFromHeading} + enableFind={enableFind} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index ef9f7f6cf5d..771a242ab2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -15,6 +15,7 @@ import { } from './collaboration/caret-presence' import { LinkEmbed } from './embed/link-embed' import { createMarkdownContentExtensions } from './extensions' +import { RichMarkdownFind } from './find' import { ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' import { MarkdownPaste } from './markdown-paste' @@ -48,8 +49,8 @@ interface MarkdownEditorExtensionOptions { * The full extension set for the live editor: the content extensions with their React node-view nodes * injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions — * `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu), - * `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed` - * (media players for standalone links). + * `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, `RichMarkdownFind` (the Cmd/Ctrl+F match + * highlights), and — when `embeds` is set — `LinkEmbed` (media players for standalone links). * * Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls * in for brand icons) stay out of the headless round-trip path, which only needs the schema. @@ -94,6 +95,7 @@ export function createMarkdownEditorExtensions({ ] : []), CodeBlockHighlight, + RichMarkdownFind, SlashCommand, Mention, RichMarkdownKeymap, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts new file mode 100644 index 00000000000..0af12c9eba2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { undoDepth } from '@tiptap/pm/history' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '../extensions' +import { getFindTally, RichMarkdownFind, setFindQuery, stepFindMatch } from './find-extension' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mountEditor(markdown: string): Editor { + const element = document.createElement('div') + document.body.append(element) + editor = new Editor({ + element, + extensions: [...createMarkdownContentExtensions(), RichMarkdownFind], + }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor +} + +/** The painted highlights, in document order, with the active one marked. */ +function paintedMatches(instance: Editor): string[] { + return Array.from(instance.view.dom.querySelectorAll('.rich-find-match')).map((element) => + element.classList.contains('rich-find-match-active') + ? `[${element.textContent}]` + : (element.textContent ?? '') + ) +} + +describe('RichMarkdownFind', () => { + it('paints nothing until a term is set', () => { + const instance = mountEditor('alpha beta alpha') + expect(paintedMatches(instance)).toEqual([]) + expect(getFindTally(instance.state).matches).toHaveLength(0) + }) + + it('paints every match and marks the first one active', () => { + const instance = mountEditor('alpha beta alpha') + setFindQuery(instance, 'alpha') + expect(paintedMatches(instance)).toEqual(['[alpha]', 'alpha']) + }) + + it('steps the active match forward and backward, wrapping at both ends', () => { + const instance = mountEditor('one one one') + setFindQuery(instance, 'one') + + stepFindMatch(instance, 1) + expect(paintedMatches(instance)).toEqual(['one', '[one]', 'one']) + + stepFindMatch(instance, 1) + expect(paintedMatches(instance)).toEqual(['one', 'one', '[one]']) + + // Past the end wraps to the first, and back past the start wraps to the last. + stepFindMatch(instance, 1) + expect(paintedMatches(instance)).toEqual(['[one]', 'one', 'one']) + stepFindMatch(instance, -1) + expect(paintedMatches(instance)).toEqual(['one', 'one', '[one]']) + }) + + it('re-searches when the document changes under a live search', () => { + const instance = mountEditor('alpha') + setFindQuery(instance, 'alpha') + expect(getFindTally(instance.state).matches).toHaveLength(1) + + instance.commands.insertContentAt(instance.state.doc.content.size, ' and alpha again') + expect(getFindTally(instance.state).matches).toHaveLength(2) + expect(paintedMatches(instance)).toEqual(['[alpha]', 'alpha']) + }) + + it('drops a match the document no longer contains, without leaving a stale highlight', () => { + const instance = mountEditor('alpha beta') + setFindQuery(instance, 'beta') + expect(paintedMatches(instance)).toEqual(['[beta]']) + + instance.commands.setContent('alpha only', { contentType: 'markdown' }) + expect(paintedMatches(instance)).toEqual([]) + expect(getFindTally(instance.state).matches).toHaveLength(0) + }) + + it('clamps the active index when an edit shrinks the match set', () => { + const instance = mountEditor('x x x') + setFindQuery(instance, 'x') + stepFindMatch(instance, 2) + expect(getFindTally(instance.state).activeIndex).toBe(2) + + instance.commands.setContent('x', { contentType: 'markdown' }) + const tally = getFindTally(instance.state) + expect(tally.matches).toHaveLength(1) + expect(tally.activeIndex).toBe(0) + expect(paintedMatches(instance)).toEqual(['[x]']) + }) + + it('clears every highlight when the term is emptied', () => { + const instance = mountEditor('alpha') + setFindQuery(instance, 'alpha') + expect(paintedMatches(instance)).toEqual(['[alpha]']) + + setFindQuery(instance, '') + expect(paintedMatches(instance)).toEqual([]) + }) + + it('never writes to the document, the selection, or the undo history', () => { + const instance = mountEditor('alpha beta alpha') + const before = instance.getMarkdown() + const selectionBefore = instance.state.selection.from + const undoBefore = undoDepth(instance.state) + + setFindQuery(instance, 'alpha') + stepFindMatch(instance, 1) + + expect(instance.getMarkdown()).toBe(before) + expect(instance.state.selection.from).toBe(selectionBefore) + // A search that added an undo step would make the user's next Cmd+Z undo the search + // instead of their real last edit. + expect(undoDepth(instance.state)).toBe(undoBefore) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts new file mode 100644 index 00000000000..e2ac3f0a20a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts @@ -0,0 +1,151 @@ +import type { Editor } from '@tiptap/core' +import { Extension } from '@tiptap/core' +import type { EditorState } from '@tiptap/pm/state' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { EMPTY_FIND_RESULT, type FindMatch, findMatches } from './find-matches' + +/** Class on every match. The active one carries {@link ACTIVE_MATCH_CLASS} as well. */ +const MATCH_CLASS = 'rich-find-match' + +/** Class on the one match the bar is currently pointing at. Also how the hook finds it to scroll to. */ +export const ACTIVE_MATCH_CLASS = 'rich-find-match-active' + +interface RichMarkdownFindState { + query: string + matches: readonly FindMatch[] + truncated: boolean + /** 0-based index into `matches`. Meaningless, but still 0, when there are none. */ + activeIndex: number + /** + * The rendered highlights, built here rather than in the `decorations` prop. ProseMirror asks for + * decorations on every view update — including caret moves and remote cursor traffic — so building + * them there would rebuild all 500 for transactions that changed nothing about the search. + */ + decorations: DecorationSet +} + +/** What the surface reads back off the plugin. */ +export type FindTally = Pick + +/** Transaction meta the surface sets to drive the search. Absent fields keep their current value. */ +interface FindMeta { + query?: string + /** Any integer; wrapped into range against the match count, so stepping past either end cycles. */ + activeIndex?: number +} + +const RICH_FIND_PLUGIN_KEY = new PluginKey('richMarkdownFind') + +const INITIAL_STATE: RichMarkdownFindState = { + query: '', + matches: EMPTY_FIND_RESULT.matches, + truncated: false, + activeIndex: 0, + decorations: DecorationSet.empty, +} + +function wrapIndex(index: number, length: number): number { + if (length === 0) return 0 + return ((index % length) + length) % length +} + +function buildDecorations( + doc: EditorState['doc'], + matches: readonly FindMatch[], + activeIndex: number +): DecorationSet { + if (matches.length === 0) return DecorationSet.empty + return DecorationSet.create( + doc, + matches.map((match, index) => + Decoration.inline(match.from, match.to, { + class: index === activeIndex ? `${MATCH_CLASS} ${ACTIVE_MATCH_CLASS}` : MATCH_CLASS, + }) + ) + ) +} + +/** + * Renders the find highlights over the document, and owns the match set they are built from. + * + * The surface never passes matches in — it sets only the term and the active index as transaction + * meta ({@link setFindQuery}, {@link stepFindMatch}) and reads the resulting state back with + * {@link getFindTally}. Keeping the search here is what makes it survive editing: the plugin + * re-searches on any transaction that changed the document, so a highlight can never be left + * pointing at a position the edit moved or deleted (a stale `Decoration` on a removed range throws). + * + * Decorations are inline and add no node to the document, so the search leaves the markdown — and + * the collaborative Y.Doc behind it — completely untouched. Nothing here dispatches, and with no + * term set every branch short-circuits to the untouched previous state, so the plugin is inert + * until a find bar opens. + */ +export const RichMarkdownFind = Extension.create({ + name: 'richMarkdownFind', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: RICH_FIND_PLUGIN_KEY, + state: { + init: () => INITIAL_STATE, + apply(transaction, value, _oldState, newState) { + const meta = transaction.getMeta(RICH_FIND_PLUGIN_KEY) as FindMeta | undefined + const query = meta?.query ?? value.query + const requestedIndex = meta?.activeIndex ?? value.activeIndex + const unsearched = query.trim().length === 0 + if (unsearched && value.matches.length === 0) { + return query === value.query && value.activeIndex === 0 + ? value + : { ...INITIAL_STATE, query } + } + if (!transaction.docChanged && query === value.query) { + const activeIndex = wrapIndex(requestedIndex, value.matches.length) + if (activeIndex === value.activeIndex) return value + return { + ...value, + activeIndex, + decorations: buildDecorations(newState.doc, value.matches, activeIndex), + } + } + const { matches, truncated } = unsearched + ? EMPTY_FIND_RESULT + : findMatches(newState.doc, query) + const activeIndex = wrapIndex(requestedIndex, matches.length) + return { + query, + matches, + truncated, + activeIndex, + decorations: buildDecorations(newState.doc, matches, activeIndex), + } + }, + }, + props: { + decorations: (state) => RICH_FIND_PLUGIN_KEY.getState(state)?.decorations ?? null, + }, + }), + ] + }, +}) + +/** The match set, cap flag and active index the find bar renders from. */ +export function getFindTally(state: EditorState): FindTally { + return RICH_FIND_PLUGIN_KEY.getState(state) ?? INITIAL_STATE +} + +function dispatchFindMeta(editor: Editor, meta: FindMeta): void { + // `setMeta` alone leaves the transaction with no steps, so this never touches the document, + // the undo history, or the collaborative document. + editor.view.dispatch(editor.state.tr.setMeta(RICH_FIND_PLUGIN_KEY, meta)) +} + +/** Searches for `query` and makes its first match active. An empty term clears the highlights. */ +export function setFindQuery(editor: Editor, query: string): void { + dispatchFindMeta(editor, { query, activeIndex: 0 }) +} + +/** Moves the active match by `delta`, cycling past either end. */ +export function stepFindMatch(editor: Editor, delta: number): void { + dispatchFindMeta(editor, { activeIndex: getFindTally(editor.state).activeIndex + delta }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts new file mode 100644 index 00000000000..b62f392cea7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '../extensions' +import { FIND_MATCH_LIMIT, findMatches } from './find-matches' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +/** Parses markdown through the real schema, so matches are checked against real document positions. */ +function docFor(markdown: string) { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor.state.doc +} + +/** The text each match actually covers — the only assertion that proves the positions are right. */ +function matchedText(markdown: string, query: string): string[] { + const doc = docFor(markdown) + return findMatches(doc, query).matches.map((match) => doc.textBetween(match.from, match.to)) +} + +describe('findMatches', () => { + it('finds every occurrence across blocks, case-insensitively', () => { + const doc = docFor('# Report\n\nthe report is ready') + const { matches, truncated } = findMatches(doc, 'report') + expect(matches).toHaveLength(2) + expect(truncated).toBe(false) + expect(matches.map((m) => doc.textBetween(m.from, m.to))).toEqual(['Report', 'report']) + }) + + it('returns nothing for an empty, whitespace-only, or unmatched term', () => { + expect(findMatches(docFor('hello'), '').matches).toHaveLength(0) + expect(findMatches(docFor('hello'), ' ').matches).toHaveLength(0) + expect(findMatches(docFor('hello'), 'zzz').matches).toHaveLength(0) + }) + + it('folds whitespace the way the rest of the app\u2019s search does', () => { + // Inherited from `forEachSearchOccurrence`: a typed space matches a non-breaking one, so a + // term copied out of agent-written prose still finds itself. + expect(matchedText('one\u00a0two', 'one two')).toEqual(['one\u00a0two']) + }) + + it('keeps positions correct after a code point that lowercases to two characters', () => { + expect(matchedText('\u0130stanbul and target', 'target')).toEqual(['target']) + }) + + it('matches across a mark boundary within a block', () => { + // `he**llo**` is two text nodes in one paragraph; a per-text-node search would miss it. + expect(matchedText('he**llo** world', 'hello')).toEqual(['hello']) + }) + + it('never matches across a block boundary', () => { + expect(matchedText('ab\n\ncd', 'abcd')).toEqual([]) + }) + + it('never matches across an inline atom', () => { + // The image between them occupies a position; joining `a` to `b` would be a phantom match. + expect(matchedText('a![alt](https://x.com/i.png)b', 'ab')).toEqual([]) + }) + + it('keeps positions correct after an inline atom', () => { + expect(matchedText('![alt](https://x.com/i.png) target', 'target')).toEqual(['target']) + }) + + it('does not overlap matches of a self-overlapping term', () => { + expect(matchedText('aaaa', 'aa')).toEqual(['aa', 'aa']) + }) + + it('caps the match set and reports it as truncated', () => { + const doc = docFor(Array.from({ length: FIND_MATCH_LIMIT + 10 }, () => 'x').join(' ')) + const { matches, truncated } = findMatches(doc, 'x') + expect(matches).toHaveLength(FIND_MATCH_LIMIT) + expect(truncated).toBe(true) + }) + + it('honors a caller-supplied limit', () => { + const { matches, truncated } = findMatches(docFor('x x x x'), 'x', 2) + expect(matches).toHaveLength(2) + expect(truncated).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts new file mode 100644 index 00000000000..5133557d52f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts @@ -0,0 +1,108 @@ +import { forEachSearchOccurrence } from '@sim/utils/string' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** One match, as a document position range that `Decoration.inline` can be built from. */ +export interface FindMatch { + from: number + to: number +} + +export interface FindResult { + matches: readonly FindMatch[] + /** More matches existed than the cap allowed; the tail was dropped. */ + truncated: boolean +} + +/** + * Cap on matches collected per search. A find bar is a navigation affordance, not a report: past a + * few hundred hits the tally stops meaning anything, while the decoration set it would build grows + * with the document. The bar renders the cap as `500+` so the number on screen is never a lie. + */ +export const FIND_MATCH_LIMIT = 500 + +export const EMPTY_FIND_RESULT: FindResult = { matches: [], truncated: false } + +/** + * Stands in for one position of a non-text inline node (an image, a mention chip) so a match can + * never span one — searching `ab` must not join the `a` before an image to the `b` after it. U+FFFF + * is a permanent Unicode non-character, so no query can contain it and match the placeholder itself, + * and it is not whitespace, so the shared scan's whitespace folding leaves it alone. + */ +const ATOM_PLACEHOLDER = '￿' + +/** A run of the flattened block text, and the document position its first character sits at. */ +interface TextSegment { + textStart: number + docStart: number +} + +/** + * Case-insensitive, non-overlapping search of `query` across every textblock in `doc`, returning + * document position ranges. + * + * What counts as an occurrence is not decided here — {@link forEachSearchOccurrence} owns that for + * the whole app (workflow search, the canvas Note card, and this), including the whitespace fold + * that makes a typed space match a non-breaking one. This module only supplies the text to scan and + * maps the indices back to ProseMirror positions. + * + * Text is flattened per textblock rather than per text node, so a term still matches when it runs + * across a mark boundary (`he**llo**`), and never across a block boundary — which no on-screen line + * does. Each block flattens to a string whose length equals the block's content size, so a string + * index maps back to a document position by walking the segment it falls in. + */ +export function findMatches( + doc: ProseMirrorNode, + query: string, + limit: number = FIND_MATCH_LIMIT +): FindResult { + if (query.trim().length === 0) return EMPTY_FIND_RESULT + + const matches: FindMatch[] = [] + let truncated = false + + doc.descendants((node, pos) => { + if (truncated) return false + if (!node.isTextblock) return true + + // The common paragraph is a single text node, where the mapping is a constant offset and the + // segment table is pure garbage. Only a block mixing marks or atoms needs one built. + const soleChild = node.childCount === 1 ? node.firstChild : null + const soleText = soleChild?.isText ? (soleChild.text ?? null) : null + + let text = soleText ?? '' + let segments: TextSegment[] | null = null + if (soleText === null) { + const built: TextSegment[] = [] + node.forEach((child, offset) => { + built.push({ textStart: text.length, docStart: pos + 1 + offset }) + text += child.isText && child.text ? child.text : ATOM_PLACEHOLDER.repeat(child.nodeSize) + }) + segments = built + } + + let segmentIndex = 0 + forEachSearchOccurrence(text, query, (start, end) => { + if (truncated) return + if (matches.length >= limit) { + truncated = true + return + } + if (!segments) { + matches.push({ from: pos + 1 + start, to: pos + 1 + end }) + return + } + // Segments are ordered and occurrences arrive left to right, so the cursor only moves forward. + while (segmentIndex + 1 < segments.length && segments[segmentIndex + 1].textStart <= start) { + segmentIndex++ + } + const segment = segments[segmentIndex] + const from = segment.docStart + (start - segment.textStart) + matches.push({ from, to: from + (end - start) }) + }) + + // Textblocks do not nest, so there is nothing below one to search. + return false + }) + + return matches.length === 0 && !truncated ? EMPTY_FIND_RESULT : { matches, truncated } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts new file mode 100644 index 00000000000..8f9b8b15704 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts @@ -0,0 +1,2 @@ +export { RichMarkdownFind } from './find-extension' +export { type MarkdownFindController, useMarkdownFind } from './use-markdown-find' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts new file mode 100644 index 00000000000..66ceb0c6c11 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts @@ -0,0 +1,156 @@ +'use client' + +import type React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { Editor } from '@tiptap/react' +import { useFindShortcut } from '@/app/workspace/[workspaceId]/components' +import { ACTIVE_MATCH_CLASS, getFindTally, setFindQuery, stepFindMatch } from './find-extension' + +/** What the surface hands `FindBar`, plus the open state the shortcut drives. */ +export interface MarkdownFindController { + isOpen: boolean + query: string + count: number + currentIndex: number + truncated: boolean + inputRef: React.RefObject + setQuery: (query: string) => void + next: () => void + prev: () => void + close: () => void +} + +/** The only three values the bar renders, mirrored out of the plugin. */ +interface FindTally { + count: number + currentIndex: number + truncated: boolean +} + +const EMPTY_TALLY: FindTally = { count: 0, currentIndex: 0, truncated: false } + +interface UseMarkdownFindOptions { + editor: Editor | null + /** + * Whether this editor claims Cmd/Ctrl+F. Off wherever the component renders inside a preview or + * embedded pane, where the surface around it — not the document — owns the shortcut. See + * {@link useFindShortcut} for how the surfaces arbitrate. + */ + enabled: boolean +} + +/** + * Find-in-document for the rich markdown editor: owns the term, the tally and the stepping that + * `FindBar` renders, and reveals each match as it becomes active. + * + * The match set itself lives in the ProseMirror plugin (`./find-extension`), which re-searches on + * every document change. This subscribes to the editor's transactions while the bar is open and + * mirrors only the three numbers the bar shows, so a keystroke that leaves the tally identical + * re-renders nothing — the editor is configured not to re-render React on transactions, and this + * must not undo that. + */ +export function useMarkdownFind({ + editor, + enabled, +}: UseMarkdownFindOptions): MarkdownFindController { + const [isOpen, setIsOpen] = useState(false) + const [query, setQueryState] = useState('') + const [tally, setTally] = useState(EMPTY_TALLY) + const inputRef = useRef(null) + const editorRef = useRef(editor) + editorRef.current = editor + + /** + * Scrolls the active highlight into view. Read from the DOM rather than mapped from the position + * so it lands on what is actually painted — a match inside a node view (a code block, a table + * cell) is rendered by that view, and its own scroll container is the one that has to move. + */ + const revealActiveMatch = useCallback(() => { + requestAnimationFrame(() => { + editorRef.current?.view.dom + .querySelector(`.${ACTIVE_MATCH_CLASS}`) + ?.scrollIntoView({ block: 'center' }) + }) + }, []) + + /** + * The single point where plugin state becomes React state. Driven by the editor's own + * `transaction` event, which TipTap emits synchronously from every dispatch — including the ones + * `setQuery` and `step` make below, so neither needs to sync by hand. + */ + const syncTally = useCallback(() => { + const current = editorRef.current + if (!current) return + const { matches, activeIndex, truncated } = getFindTally(current.state) + setTally((previous) => + previous.count === matches.length && + previous.currentIndex === activeIndex && + previous.truncated === truncated + ? previous + : { count: matches.length, currentIndex: activeIndex, truncated } + ) + }, []) + + /** + * Keeps the tally honest while the bar is open — the document can change underneath a live search + * from the user's own typing, a collaborator, or a streaming agent edit, and the plugin re-searches + * on each of those. Not subscribed while the bar is closed, so typing costs nothing then. + */ + useEffect(() => { + if (!editor || !isOpen) return + syncTally() + editor.on('transaction', syncTally) + return () => { + editor.off('transaction', syncTally) + } + }, [editor, isOpen, syncTally]) + + const setQuery = useCallback( + (next: string) => { + setQueryState(next) + const current = editorRef.current + if (!current) return + setFindQuery(current, next) + revealActiveMatch() + }, + [revealActiveMatch] + ) + + const step = useCallback( + (delta: number) => { + const current = editorRef.current + if (!current) return + stepFindMatch(current, delta) + revealActiveMatch() + }, + [revealActiveMatch] + ) + + const next = useCallback(() => step(1), [step]) + const prev = useCallback(() => step(-1), [step]) + + /** Closing ends the search: term, highlights and active match all go. */ + const close = useCallback(() => { + setIsOpen(false) + setQueryState('') + setTally(EMPTY_TALLY) + const current = editorRef.current + if (current) setFindQuery(current, '') + }, []) + + const open = useCallback(() => setIsOpen(true), []) + useFindShortcut({ enabled, inputRef, onOpen: open }) + + return { + isOpen, + query, + count: tally.count, + currentIndex: tally.currentIndex, + truncated: tally.truncated, + inputRef, + setQuery, + next, + prev, + close, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index b148b2904f2..99e30205923 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -585,3 +585,16 @@ border-radius: 2px; pointer-events: none; } + +/* Cmd/Ctrl+F match highlights. Every hit carries the same tint the rest of the app + * paints a search match with; the active one is ringed rather than recolored, so the + * two read as one family and neither needs a token that only exists here. */ +.rich-markdown-nodes .rich-find-match { + background-color: var(--highlight-match-bg); + color: var(--highlight-match-text); + border-radius: 2px; +} + +.rich-markdown-nodes .rich-find-match-active { + box-shadow: 0 0 0 1.5px var(--highlight-match-text); +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9684f1bab84..1cbbceed720 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -16,6 +16,7 @@ import { } from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef, extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref' +import { FindBar } from '@/app/workspace/[workspaceId]/components' import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' import { useAddToChat } from '@/hooks/use-add-to-chat' @@ -39,6 +40,7 @@ import { import { nextCollabReadiness } from './collaboration/readiness' import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from './editor-extensions' +import { useMarkdownFind } from './find' import { findHeadingPos } from './heading-anchors' import { moveDraggedImageNode } from './image-drag-move' import { extractImageFiles, findHostedImageAttrs, shouldSkipFileUpload } from './image-paste' @@ -159,6 +161,12 @@ interface RichMarkdownEditorProps { * {@link isUntitledName}. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** + * Claim Cmd/Ctrl+F for find-in-document. Off by default, because this editor also renders as a + * preview pane beside something that owns the shortcut itself. Every find surface binds its own + * listener, so only one may be enabled at a time — see {@link useFindShortcut}. + */ + enableFind?: boolean } /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ @@ -180,6 +188,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ disableTagging, collaborative = false, onDeriveTitleFromHeading, + enableFind = false, }: RichMarkdownEditorProps) { const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' @@ -253,6 +262,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} onDeriveTitleFromHeading={onDeriveTitleFromHeading} + enableFind={enableFind} /> ) }) @@ -283,6 +293,8 @@ interface LoadedRichMarkdownEditorProps { onCollabReadyChange: (ready: boolean) => void /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** See {@link RichMarkdownEditorProps.enableFind}. */ + enableFind: boolean } interface SettledContent { @@ -314,6 +326,7 @@ export function LoadedRichMarkdownEditor({ onSaveShortcut, onCollabReadyChange, onDeriveTitleFromHeading, + enableFind, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -1202,6 +1215,8 @@ export function LoadedRichMarkdownEditor({ useSelectionCopyBridge(containerRef, buildSelectionContext) + const find = useMarkdownFind({ editor, enabled: enableFind }) + // Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet // seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held // until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder @@ -1209,42 +1224,61 @@ export function LoadedRichMarkdownEditor({ const showPlaceholder = collaborationEnabled && !collabReady return ( -
- {editor && ( - + {find.isOpen && ( + )} - {editor && } - {editor && } - { - const input = event.currentTarget - const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/')) - const at = - pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 - pendingImagePosRef.current = null - input.value = '' - if (images.length > 0) void insertImagesRef.current(images, at) - }} - /> - {showPlaceholder && placeholderContent && ( - - )} - +
+ {editor && ( + + )} + {editor && } + {editor && } + { + const input = event.currentTarget + const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/')) + const at = + pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 + pendingImagePosRef.current = null + input.value = '' + if (images.length > 0) void insertImagesRef.current(images, at) + }} + /> + {showPlaceholder && placeholderContent && ( + + )} + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f81334b9512..dc7d32ec991 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -70,6 +70,7 @@ import { resourceListState, selectionLabel, timeCell, + useFindShortcut, useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { @@ -1584,25 +1585,14 @@ export function Files() { /** * Overrides the browser's Cmd/Ctrl+F with the in-list find while the list is - * showing. Skipped when a file is open — its editor owns the shortcut there — - * and when another surface already claimed the press. + * showing. Handed to the open file's editor instead once one is open. */ - useEffect(() => { - const handleFindShortcut = (e: KeyboardEvent) => { - if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return - if (e.key.toLowerCase() !== 'f') return - if (fileIdFromRouteRef.current) return - if (e.defaultPrevented) return - e.preventDefault() - setFindOpen(true) - requestAnimationFrame(() => { - findInputRef.current?.focus() - findInputRef.current?.select() - }) - } - document.addEventListener('keydown', handleFindShortcut) - return () => document.removeEventListener('keydown', handleFindShortcut) - }, []) + const handleFindOpen = useCallback(() => setFindOpen(true), []) + useFindShortcut({ + enabled: !fileIdFromRoute, + inputRef: findInputRef, + onOpen: handleFindOpen, + }) const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { @@ -2138,6 +2128,7 @@ export function Files() { discardRef={discardRef} collaborative onDeriveTitleFromHeading={handleDeriveTitleFromHeading} + enableFind /> ) diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index abac5dc7f45..099b6a308bf 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + forEachSearchOccurrence, formatQuotedNameList, isVersionedType, normalizeEmail, @@ -205,3 +206,36 @@ describe('projectEscapedMarkdownForSearch', () => { expect(starts[text.length]).toBe(source.length) }) }) + +describe('forEachSearchOccurrence', () => { + const spans = (text: string, query: string, caseSensitive?: boolean) => { + const found: string[] = [] + forEachSearchOccurrence( + text, + query, + (start, end) => found.push(text.slice(start, end)), + caseSensitive + ) + return found + } + + it('visits every non-overlapping occurrence, case-insensitively by default', () => { + expect(spans('Ab ab AB', 'ab')).toEqual(['Ab', 'ab', 'AB']) + expect(spans('aaaa', 'aa')).toEqual(['aa', 'aa']) + expect(spans('Ab', 'ab', true)).toEqual([]) + }) + + it('folds whitespace so a typed space matches a non-breaking one', () => { + expect(spans('a\u00a0b', 'a b')).toEqual(['a\u00a0b']) + }) + + it('visits nothing for an empty query', () => { + expect(spans('abc', '')).toEqual([]) + }) + + it('reports bounds into the caller\u2019s own string after a length-changing lowercase', () => { + // '\u0130'.toLowerCase() is TWO characters. A plain lowercase would slide every later index by + // one, so the caller would slice the wrong span out of the string it passed in. + expect(spans('\u0130xyz target', 'target')).toEqual(['target']) + }) +}) diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 8d9a26b7a11..78327fe1159 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -207,6 +207,28 @@ export function foldSearchWhitespace(value: string): string { return value.replace(/\s/g, ' ') } +/** + * Lowercases without ever changing the string's length. + * + * A plain `toLowerCase()` cannot be used where an index into the result has to + * address the same character of the input: a few code points lowercase to more + * than one (`'\u0130'.toLowerCase()` is two characters), which slides every + * later index. Characters that would grow are left alone — they simply match + * case-sensitively. The whole-string form is tried first because it is a single + * intrinsic and is length-preserving for every input that contains no such code + * point, i.e. essentially all of them. + */ +function lowerPreservingLength(value: string): string { + const lowered = value.toLowerCase() + if (lowered.length === value.length) return lowered + let result = '' + for (const char of value) { + const loweredChar = char.toLowerCase() + result += loweredChar.length === char.length ? loweredChar : char + } + return result +} + /** * Visits every occurrence of `query` in `text`, without overlaps. * @@ -217,8 +239,11 @@ export function foldSearchWhitespace(value: string): string { * panel counts a match the card never paints, which is exactly the bug that * arrived when only the whitespace fold was shared and the scan was not. * - * Whitespace is folded first (see {@link foldSearchWhitespace}) and the fold is - * one-to-one, so both bounds index the caller's own unfolded string. + * Whitespace is folded first (see {@link foldSearchWhitespace}) and case is + * folded with {@link lowerPreservingLength}; both are one-to-one, so the bounds + * index the caller's own unfolded string. A plain `toLowerCase()` here would + * break that guarantee for the handful of code points that lowercase to two — + * every occurrence after one would be reported a character late. */ export function forEachSearchOccurrence( text: string, @@ -230,7 +255,7 @@ export function forEachSearchOccurrence( const normalize = (value: string) => { const folded = foldSearchWhitespace(value) - return caseSensitive ? folded : folded.toLowerCase() + return caseSensitive ? folded : lowerPreservingLength(folded) } const haystack = normalize(text) const needle = normalize(query) From 6f67b57152ae2fba4e55e6fff396fb38ebc2286c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 27 Aug 2026 20:30:09 -0700 Subject: [PATCH 2/2] fix(files): close three find edge cases from review - Keep context-sensitive lowercasing in the length-preserving case fold. A word-final sigma lowercases differently in a string than on its own, so folding character by character let one unrelated expanding code point change how every sigma in the string matched. The fallback now reads each character's replacement out of the whole-string result. - Decline Cmd/Ctrl+F while a collaborative document is still seeding. The text on screen then belongs to the read-only placeholder's editor, not the empty hidden one find is attached to, so the bar answered "No results" for visible text. The browser's native find reads the placeholder correctly in that window; the shortcut becomes ours once the seed lands. - Re-apply a pending term when the editor instance arrives, so a query typed before TipTap mounts is searched instead of sitting at zero matches until the next keystroke. --- .../find/find-extension.test.ts | 9 +++++++++ .../rich-markdown-editor/find/use-markdown-find.ts | 12 ++++++++++++ .../rich-markdown-editor/rich-markdown-editor.tsx | 10 ++++++++-- packages/utils/src/string.test.ts | 7 +++++++ packages/utils/src/string.ts | 14 +++++++++++++- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts index 0af12c9eba2..2f730b40771 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts @@ -96,6 +96,15 @@ describe('RichMarkdownFind', () => { expect(paintedMatches(instance)).toEqual(['[x]']) }) + it('searches a term applied before any other transaction', () => { + // The hook re-applies a pending term the moment the editor exists; setting a query as the very + // first thing that happens to a fresh editor must land, not wait for a later transaction. + const instance = mountEditor('alpha beta') + setFindQuery(instance, 'beta') + expect(getFindTally(instance.state).matches).toHaveLength(1) + expect(paintedMatches(instance)).toEqual(['[beta]']) + }) + it('clears every highlight when the term is emptied', () => { const instance = mountEditor('alpha') setFindQuery(instance, 'alpha') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts index 66ceb0c6c11..b0a97d5a6f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts @@ -105,6 +105,18 @@ export function useMarkdownFind({ } }, [editor, isOpen, syncTally]) + /** + * Re-applies the live term to a newly arrived editor. `useEditor` returns null on the first render, + * so a term typed into the bar before the editor mounts would be held in React and never searched, + * leaving the bar at "No results" until the next keystroke pushed it through. + */ + const queryRef = useRef(query) + queryRef.current = query + useEffect(() => { + if (!editor || queryRef.current.length === 0) return + setFindQuery(editor, queryRef.current) + }, [editor]) + const setQuery = useCallback( (next: string) => { setQueryState(next) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 1cbbceed720..e9c171c59fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -1215,14 +1215,20 @@ export function LoadedRichMarkdownEditor({ useSelectionCopyBridge(containerRef, buildSelectionContext) - const find = useMarkdownFind({ editor, enabled: enableFind }) - // Show the read-only placeholder (the already-fetched markdown) whenever a collaborative doc has not yet // seeded — including during an agent stream that begins before the seed lands. Streamed diffs are held // until `collabReady` (see the streaming effect), so before then the editor is empty; the placeholder // shows the base content until the seed swaps it in, avoiding both a blank frame and a garbled merge. const showPlaceholder = collaborationEnabled && !collabReady + /** + * Find is off while the placeholder is up. The text on screen then belongs to the placeholder's own + * editor, not to `editor` — which is still empty and hidden — so searching `editor` would answer + * "No results" for text the user can see. Declining the shortcut hands it back to the browser, whose + * native find reads the rendered placeholder correctly; it becomes ours once the seed lands. + */ + const find = useMarkdownFind({ editor, enabled: enableFind && !showPlaceholder }) + return ( // The find bar is a sibling of the scroller, not a child: pinned inside `containerRef` it would // scroll away with the document the moment stepping moved the view. diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index 099b6a308bf..718079ebf2a 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -233,6 +233,13 @@ describe('forEachSearchOccurrence', () => { expect(spans('abc', '')).toEqual([]) }) + it('keeps context-sensitive lowercasing in the length-preserving fallback', () => { + // '\u0130' expands, so the whole-string fast path is unavailable. Folding the rest character by + // character would lowercase the word-final '\u03a3' to '\u03c3' instead of '\u03c2', making one + // unrelated code point change how every sigma in the string matches. + expect(spans('\u0130\u03a3', '\u0130\u03c2')).toEqual(['\u0130\u03a3']) + }) + it('reports bounds into the caller\u2019s own string after a length-changing lowercase', () => { // '\u0130'.toLowerCase() is TWO characters. A plain lowercase would slide every later index by // one, so the caller would slice the wrong span out of the string it passed in. diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 78327fe1159..c4ba5d8d221 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -217,14 +217,26 @@ export function foldSearchWhitespace(value: string): string { * case-sensitively. The whole-string form is tried first because it is a single * intrinsic and is length-preserving for every input that contains no such code * point, i.e. essentially all of them. + * + * The fallback still reads each character's replacement out of the whole-string + * result rather than lowercasing it in isolation, because some lowercasing is + * context-sensitive: a word-final `\u03a3` lowercases to `\u03c2` in the string + * but to `\u03c3` on its own. Folding character by character would make one + * expanding code point elsewhere in the string silently change how every sigma + * in it matches. */ function lowerPreservingLength(value: string): string { const lowered = value.toLowerCase() if (lowered.length === value.length) return lowered let result = '' + let loweredOffset = 0 for (const char of value) { const loweredChar = char.toLowerCase() - result += loweredChar.length === char.length ? loweredChar : char + result += + loweredChar.length === char.length + ? lowered.slice(loweredOffset, loweredOffset + loweredChar.length) + : char + loweredOffset += loweredChar.length } return result }