/** * Custom CodeMirror search/replace panel using the project UI Kit. * * Replaces the default CodeMirror search panel with a styled version * that uses our Input, Button, and Tooltip components for consistent * design language across the app. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { closeSearchPanel, findNext, findPrevious, getSearchQuery, replaceAll, replaceNext, SearchQuery, setSearchQuery, } from '@codemirror/search'; import { EditorView, type Panel } from '@codemirror/view'; import { useAppTranslation } from '@features/localization/renderer'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { ArrowDown, ArrowUp, CaseSensitive, ChevronDown, ChevronRight, Regex, WholeWord, X, } from 'lucide-react'; import type { EditorState } from '@codemirror/state'; import type { ViewUpdate } from '@codemirror/view'; // ============================================================================= // Constants // ============================================================================= const MAX_MATCH_COUNT = 999; const SHORTCUT_PREVIOUS_MATCH = '⇧Enter'; const SHORTCUT_NEXT_MATCH = 'Enter'; const SHORTCUT_CLOSE = 'Esc'; // ============================================================================= // SearchToggleButton // ============================================================================= interface SearchToggleButtonProps { active: boolean; onClick: () => void; tooltip: string; shortcut?: string; children: React.ReactNode; } const SearchToggleButton = React.memo(function SearchToggleButton({ active, onClick, tooltip, shortcut, children, }: SearchToggleButtonProps) { return ( {tooltip} {shortcut && {shortcut}} ); }); // ============================================================================= // Match counter // ============================================================================= function countMatches(query: SearchQuery, state: EditorState): number { if (!query.valid || !query.search) return 0; try { const cursor = query.getCursor(state); let count = 0; while (!cursor.next().done) { count++; if (count > MAX_MATCH_COUNT) return -1; } return count; } catch { return 0; } } // ============================================================================= // EditorSearchPanelContent // ============================================================================= interface EditorSearchPanelContentProps { view: EditorView; initialSearch: string; initialReplace: string; initialCaseSensitive: boolean; initialRegexp: boolean; initialWholeWord: boolean; registerUpdateNotifier: (cb: () => void) => void; } const EditorSearchPanelContent = ({ view, initialSearch, initialReplace, initialCaseSensitive, initialRegexp, initialWholeWord, registerUpdateNotifier, }: EditorSearchPanelContentProps) => { const { t } = useAppTranslation('team'); const [searchText, setSearchText] = useState(initialSearch); const [replaceText, setReplaceText] = useState(initialReplace); const [caseSensitive, setCaseSensitive] = useState(initialCaseSensitive); const [useRegexp, setUseRegexp] = useState(initialRegexp); const [wholeWord, setWholeWord] = useState(initialWholeWord); const [showReplace, setShowReplace] = useState(false); const [updateTick, setUpdateTick] = useState(0); const searchInputRef = useRef(null); // Focus search input on mount useEffect(() => { requestAnimationFrame(() => { searchInputRef.current?.focus(); searchInputRef.current?.select(); }); }, []); // Build query object (memoized) const query = useMemo( () => new SearchQuery({ search: searchText, replace: replaceText, caseSensitive, regexp: useRegexp, wholeWord, }), [searchText, replaceText, caseSensitive, useRegexp, wholeWord] ); // Dispatch search query to CodeMirror for highlighting useEffect(() => { view.dispatch({ effects: setSearchQuery.of(query) }); }, [query, view]); // Register for editor updates (doc changes → recount via updateTick) useEffect(() => { registerUpdateNotifier(() => setUpdateTick((t) => t + 1)); }, [registerUpdateNotifier]); // Match count — derived from query + document state // updateTick triggers recount on document changes (e.g. after replace) const matchCount = useMemo( () => countMatches(query, view.state), // eslint-disable-next-line react-hooks/exhaustive-deps -- updateTick is a proxy dep for view.state changes [query, view, updateTick] ); // Navigation const handleFindNext = useCallback(() => { findNext(view); }, [view]); const handleFindPrev = useCallback(() => { findPrevious(view); }, [view]); // Replace const handleReplaceNext = useCallback(() => { replaceNext(view); }, [view]); const handleReplaceAll = useCallback(() => { replaceAll(view); }, [view]); // Close const handleClose = useCallback(() => { closeSearchPanel(view); view.focus(); }, [view]); // Keyboard handlers const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); handleClose(); } else if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { findPrevious(view); } else { findNext(view); } } }, [view, handleClose] ); const handleReplaceKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); handleClose(); } else if (e.key === 'Enter') { e.preventDefault(); handleReplaceNext(); } }, [handleClose, handleReplaceNext] ); // Match count display const matchCountText = searchText ? matchCount === -1 ? `${MAX_MATCH_COUNT}+` : matchCount === 0 ? 'No results' : `${matchCount} found` : ''; return (
{/* Search row */}
{/* Toggle replace visibility */} {t('editor.search.toggleReplace')} {/* Search input */} setSearchText(e.target.value)} onKeyDown={handleSearchKeyDown} spellCheck={false} /> {/* Toggle buttons */} setCaseSensitive((prev) => !prev)} tooltip="Match Case" > setWholeWord((prev) => !prev)} tooltip="Match Whole Word" > setUseRegexp((prev) => !prev)} tooltip="Use Regular Expression" > {/* Separator */}
{/* Match count */} {matchCountText && ( {matchCountText} )} {/* Navigation */} {t('editor.searchPanel.previousMatch')}{' '} {SHORTCUT_PREVIOUS_MATCH} {t('editor.searchPanel.nextMatch')}{' '} {SHORTCUT_NEXT_MATCH} {/* Close */} {t('editor.searchPanel.close')}{' '} {SHORTCUT_CLOSE}
{/* Replace row */} {showReplace && (
{/* Spacer to align with search input */}
setReplaceText(e.target.value)} onKeyDown={handleReplaceKeyDown} spellCheck={false} /> {t('editor.searchPanel.replaceNext')} {t('editor.searchPanel.replaceAll')}
)}
); }; // ============================================================================= // Panel factory for CodeMirror // ============================================================================= export function createSearchPanel(view: EditorView): Panel { const dom = document.createElement('div'); const root = createRoot(dom); // Get initial values const existingQuery = getSearchQuery(view.state); const sel = view.state.selection.main; const selText = sel.empty ? '' : view.state.sliceDoc(sel.from, sel.to); const initialSearch = selText && !selText.includes('\n') ? selText : existingQuery.search; // Mutable ref for update notifications from CodeMirror let notifyUpdate: (() => void) | null = null; root.render( { notifyUpdate = cb; }} /> ); return { dom, top: true, update(update: ViewUpdate) { if (update.docChanged) { notifyUpdate?.(); } }, destroy() { notifyUpdate = null; root.unmount(); }, }; } // ============================================================================= // Theme: panel container + search match highlighting // ============================================================================= export const editorSearchPanelTheme = EditorView.theme({ '.cm-panels': { backgroundColor: 'var(--color-surface)', color: 'var(--color-text)', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, '.cm-panels-top': { borderBottom: '1px solid var(--color-border)', }, '.cm-panels-bottom': { borderTop: '1px solid var(--color-border)', }, // Search match highlighting in editor content '.cm-searchMatch': { backgroundColor: 'var(--highlight-bg-inactive)', borderRadius: '2px', }, '.cm-searchMatch-selected': { backgroundColor: 'var(--highlight-bg) !important', borderRadius: '2px', }, });