fix(ui): add in-document search to the Markdown file preview (#2697)

fix(ui): add in-document search to the Markdown file preview
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:48:56 +03:00
committed by GitHub
16 changed files with 587 additions and 27 deletions
+107 -27
View File
@@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { GoToLineDialog } from './GoToLineDialog';
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
import { PreviewToggleButton } from './PreviewToggleButton';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
@@ -968,6 +969,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
// In-preview find for the rendered Markdown preview (Ctrl/Cmd+F).
const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false);
const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0);
const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
@@ -2915,6 +2921,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setIsGoToLineOpen(true);
});
// Ctrl/Cmd+F opens the in-preview find bar for the rendered Markdown
// preview. In edit mode CodeMirror owns the shortcut, so this handler is
// active only while the preview is shown.
React.useEffect(() => {
if (!isMarkdown || getMdViewMode() !== 'preview') {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) {
return;
}
if (event.key.toLowerCase() !== 'f') {
return;
}
const target = event.target;
if (target instanceof Element && target.closest('[role="dialog"]')) {
return;
}
event.preventDefault();
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [getMdViewMode, isMarkdown]);
const editorFontSize = useUIStore((state) => state.editorFontSize);
const editorExtensions = React.useMemo(() => {
@@ -3366,6 +3400,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
)}
{isMarkdown && getMdViewMode() === 'preview' && (
withTooltip(t('filesView.editor.findInFile'),
<Button
variant="ghost"
size="sm"
onClick={() => {
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
}}
className="size-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title={t('filesView.editor.findInFile')}
>
<Icon name="search" className="size-4" />
</Button>
)
)}
{isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
<Tooltip>
<TooltipTrigger asChild>
@@ -3842,34 +3893,50 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
</ErrorBoundary>
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
<div className="relative h-full min-h-0">
<div
className="oc-file-preview h-full overflow-auto p-3"
ref={(node) => {
markdownPreviewRef.current = node;
mdPreviewContainerRef.current = node;
}}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
</ErrorBoundary>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
</div>
{!isFullscreen && (
<MarkdownPreviewSearch
containerRef={mdPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
/>
)}
</div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
isHtmlAssetAuthLoading ? (
@@ -4213,7 +4280,20 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : null}
</div>
) : isMarkdown && getMdViewMode() === 'preview' ? (
<div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}>
<div
className="oc-file-preview h-full overflow-auto p-4"
ref={(node) => {
markdownPreviewRef.current = node;
mdFullscreenPreviewContainerRef.current = node;
}}
>
<MarkdownPreviewSearch
containerRef={mdFullscreenPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
className="right-4 top-16"
/>
{selectedFile ? (
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { findMatchRanges } from './markdownPreviewFind';
describe('findMatchRanges', () => {
test('returns no ranges for an empty or whitespace-only query', () => {
expect(findMatchRanges('hello world', '')).toEqual([]);
expect(findMatchRanges('hello world', ' ')).toEqual([]);
});
test('returns no ranges when the query does not occur', () => {
expect(findMatchRanges('hello world', 'nope')).toEqual([]);
});
test('finds all non-overlapping occurrences', () => {
expect(findMatchRanges('the quick brown fox jumps over the lazy dog', 'the')).toEqual([
{ start: 0, end: 3 },
{ start: 31, end: 34 },
]);
});
test('matches case-insensitively', () => {
expect(findMatchRanges('Hello HELLO hello', 'hello')).toEqual([
{ start: 0, end: 5 },
{ start: 6, end: 11 },
{ start: 12, end: 17 },
]);
});
test('scans non-overlapping matches like standard find-in-page', () => {
expect(findMatchRanges('aaaa', 'aaa')).toEqual([{ start: 0, end: 3 }]);
});
test('trims the query before matching', () => {
expect(findMatchRanges('alpha beta', ' beta ')).toEqual([{ start: 6, end: 10 }]);
});
test('handles a query longer than the text', () => {
expect(findMatchRanges('abc', 'abcdef')).toEqual([]);
});
});
@@ -0,0 +1,344 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { findMatchRanges } from './markdownPreviewFind';
/**
* In-preview text search for the rendered Markdown file preview.
*
* The preview renders as plain DOM (no iframe/shadow root), so browser-native
* find works on web but the Electron desktop shell has no find-in-page
* implementation at all, and CodeMirror's search only exists in edit mode.
* This widget provides the find shortcut behavior (Ctrl/Cmd+F) and a compact
* search bar with match highlighting, navigation, and a live count, scoped to
* the preview container.
*
* The rendered DOM is owned by the markdown renderer (block-level morphdom
* reconciliation), so highlights are re-applied whenever the renderer mutates
* the container (theme or content changes) via a MutationObserver; mutations
* produced by this widget itself are ignored.
*/
const MARK_ATTR = 'data-md-find';
const CURRENT_MARK_ATTR = 'data-md-find-current';
const MARK_CLASS = 'rounded-[2px] bg-status-warning/30 text-foreground';
const CURRENT_MARK_CLASS = 'rounded-[2px] bg-status-warning/60 text-foreground';
/** Keystrokes re-walk the whole preview, so coalesce bursts of typing. */
const SEARCH_DEBOUNCE_MS = 120;
const isMarkElement = (node: Node): boolean => {
return node instanceof Element && node.hasAttribute(MARK_ATTR);
};
const clearHighlights = (container: HTMLElement): void => {
container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
const parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
parent.normalize();
});
};
const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
clearHighlights(container);
const normalized = query.trim().toLowerCase();
if (!normalized) {
return [];
}
const marks: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) {
return NodeFilter.FILTER_REJECT;
}
// Skipping svg (mermaid) keeps the highlight pass from corrupting
// diagram rendering; script/style content is never visible anyway.
if (parent.closest('svg, script, style')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const textNodes: Text[] = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (node instanceof Text) {
textNodes.push(node);
}
}
for (const node of textNodes) {
const text = node.nodeValue ?? '';
if (!text) {
continue;
}
const ranges = findMatchRanges(text, normalized);
if (ranges.length === 0) {
continue;
}
const parent = node.parentNode;
if (!parent) {
continue;
}
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, range.start)));
}
const mark = document.createElement('mark');
mark.setAttribute(MARK_ATTR, '');
mark.className = MARK_CLASS;
mark.textContent = text.slice(range.start, range.end);
fragment.appendChild(mark);
marks.push(mark);
cursor = range.end;
}
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.slice(cursor)));
}
parent.replaceChild(fragment, node);
}
return marks;
};
type MarkdownPreviewSearchProps = {
/** The scrollable preview container whose rendered text is searched. */
containerRef: React.RefObject<HTMLDivElement | null>;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Bumped every time the find shortcut is pressed to re-focus the input. */
focusNonce: number;
/** Layout overrides for the floating bar (position, offsets). */
className?: string;
};
export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
containerRef,
open,
onOpenChange,
focusNonce,
className,
}) => {
const { t } = useI18n();
const [query, setQuery] = React.useState('');
const [total, setTotal] = React.useState(0);
const [index, setIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const marksRef = React.useRef<HTMLElement[]>([]);
const queryRef = React.useRef(query);
queryRef.current = query;
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Focus returns here when the bar closes, so Escape does not strand focus.
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const runSearch = React.useCallback((nextQuery: string) => {
const container = containerRef.current;
if (!container) {
marksRef.current = [];
setTotal(0);
setIndex(0);
return;
}
marksRef.current = applySearch(container, nextQuery);
setTotal(marksRef.current.length);
setIndex(0);
}, [containerRef]);
const scheduleSearch = React.useCallback((nextQuery: string) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
runSearch(nextQuery);
}, SEARCH_DEBOUNCE_MS);
}, [runSearch]);
React.useEffect(() => () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}, []);
const close = React.useCallback(() => {
onOpenChange(false);
const target = returnFocusRef.current;
returnFocusRef.current = null;
if (target?.isConnected) {
target.focus();
}
}, [onOpenChange]);
// Re-apply highlights when the renderer re-morphs the container (theme or
// content changes), ignoring mutations this widget produces itself. Only
// active while the bar is open; closing clears the highlights.
React.useEffect(() => {
const container = containerRef.current;
if (!open || !container) {
return;
}
const observer = new MutationObserver((records) => {
const fromUs = records.some((record) => {
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
return true;
}
return [...record.addedNodes].some((node) => isMarkElement(node));
});
if (fromUs) {
return;
}
runSearch(queryRef.current);
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
clearHighlights(container);
};
}, [containerRef, open, runSearch]);
// Focus the input when the bar opens, remembering what to restore on close.
React.useEffect(() => {
if (!open) {
return;
}
const previous = document.activeElement;
if (previous instanceof HTMLElement && !returnFocusRef.current) {
returnFocusRef.current = previous;
}
inputRef.current?.focus();
}, [open]);
// Pressing the find shortcut again re-focuses and re-selects the query.
React.useEffect(() => {
if (open && focusNonce > 0) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [open, focusNonce]);
// Keep the current-match highlight and scroll it into view.
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
container.querySelectorAll(`mark[${CURRENT_MARK_ATTR}]`).forEach((mark) => {
mark.removeAttribute(CURRENT_MARK_ATTR);
mark.className = MARK_CLASS;
});
if (total === 0) {
return;
}
const current = marksRef.current[Math.min(Math.max(index, 0), total - 1)];
if (!current) {
return;
}
current.setAttribute(CURRENT_MARK_ATTR, '');
current.className = CURRENT_MARK_CLASS;
current.scrollIntoView({ block: 'nearest' });
}, [containerRef, index, total]);
const goToNext = React.useCallback(() => {
setIndex((current) => (total === 0 ? 0 : (current + 1) % total));
}, [total]);
const goToPrevious = React.useCallback(() => {
setIndex((current) => (total === 0 ? 0 : (current - 1 + total) % total));
}, [total]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
goToPrevious();
} else {
goToNext();
}
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
}, [close, goToNext, goToPrevious]);
if (!open) {
return null;
}
return (
<div className={cn('absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg', className)}>
<Icon name="search" className="ml-0.5 size-3.5 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
scheduleSearch(event.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={t('filesView.preview.find.placeholder')}
aria-label={t('filesView.preview.find.placeholder')}
className="h-7 w-40 rounded-md px-2 py-0 text-sm md:w-56"
/>
<span
className="min-w-12 px-1 text-center typography-micro text-muted-foreground tabular-nums"
aria-live="polite"
aria-label={total > 0
? t('filesView.preview.find.countAria', { current: index + 1, total })
: undefined}
>
{query.trim() && total === 0
? t('filesView.preview.find.noMatches')
: total > 0
? `${index + 1}/${total}`
: ''}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={goToPrevious}
title={t('filesView.preview.find.previousAria')}
aria-label={t('filesView.preview.find.previousAria')}
disabled={total === 0}
>
<Icon name="arrow-up" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={goToNext}
title={t('filesView.preview.find.nextAria')}
aria-label={t('filesView.preview.find.nextAria')}
disabled={total === 0}
>
<Icon name="arrow-down" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={close}
title={t('filesView.preview.find.closeAria')}
aria-label={t('filesView.preview.find.closeAria')}
>
<Icon name="close" className="size-3.5" />
</Button>
</div>
);
};
@@ -0,0 +1,23 @@
/**
* Case-insensitive substring match ranges over a single text string, using
* the same non-overlapping `String.prototype.indexOf` scan semantics as
* standard find-in-page (e.g. "aaa" in "aaaa" yields a single [0,3]).
*/
export const findMatchRanges = (text: string, query: string): Array<{ start: number; end: number }> => {
const normalized = query.trim().toLowerCase();
const ranges: Array<{ start: number; end: number }> = [];
if (!normalized) {
return ranges;
}
const lower = text.toLowerCase();
let cursor = 0;
while (true) {
const index = lower.indexOf(normalized, cursor);
if (index === -1) {
break;
}
ranges.push({ start: index, end: index + normalized.length });
cursor = index + normalized.length;
}
return ranges;
};