/* Copyright 2026 Marimo. All rights reserved. */ import type { EditorView } from "@codemirror/view"; import { useAtom, useAtomValue } from "jotai"; import { ArrowLeftIcon, ArrowRightIcon, CaseSensitiveIcon, RegexIcon, WholeWordIcon, XIcon, } from "lucide-react"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { FocusScope } from "react-aria"; import { findNext, findPrev, getMatches, replaceAll, replaceNext, } from "@/core/codemirror/find-replace/navigate"; import { clearGlobalSearchQuery, setGlobalSearchQuery, } from "@/core/codemirror/find-replace/search-highlight"; import { findReplaceAtom, openFindReplacePanel, } from "@/core/codemirror/find-replace/state"; import { hotkeysAtom } from "@/core/config/config"; import { useHotkey } from "@/hooks/useHotkey"; import { UndoButton } from "../buttons/undo-button"; import { KeyboardHotkeys } from "../shortcuts/renderShortcut"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Toggle } from "../ui/toggle"; import { Tooltip } from "../ui/tooltip"; import { toast } from "../ui/use-toast"; export const FindReplace: React.FC = () => { const [isFocused, setIsFocused] = useState(false); const [state, dispatch] = useAtom(findReplaceAtom); const [matches, setMatches] = useState<{ count: number; position: Map>; }>(); const findInputRef = useRef(null); const hotkeys = useAtomValue(hotkeysAtom); useHotkey("cell.findAndReplace", () => { // if already open and focused, fallback to default behavior if (isFocused && state.isOpen) { return false; } return openFindReplacePanel(); }); const resetMatches = () => { const matches = getMatches(); // False count means an invalid regex setMatches(matches === false ? undefined : matches); }; useEffect(() => { if (state.isOpen && findInputRef.current) { findInputRef.current.focus(); // Focus the input findInputRef.current.select(); // Select all text in the input } }, [state.isOpen]); // Depend on isOpen to trigger when the panel opens useEffect(() => { if (!state.isOpen) { clearGlobalSearchQuery(); return; } if (state.findText === "") { setMatches(undefined); clearGlobalSearchQuery(); return; } resetMatches(); setGlobalSearchQuery(); }, [ // Re-search when any of these change state.findText, state.isOpen, state.caseSensitive, state.regexp, state.wholeWord, ]); if (!state.isOpen) { return null; } const selection = state.currentView; const currentMatch = selection && matches ? matches.position .get(selection.view) ?.get(`${selection.range.from}:${selection.range.to}`) : undefined; return (
setIsFocused(true)} onClick={(e) => { e.stopPropagation(); e.preventDefault(); }} onBlur={() => setIsFocused(false)} className="fixed top-0 right-0 w-[500px] flex flex-col bg-(--sage-1) p-4 z-50 mt-2 mr-3 rounded-md shadow-lg border gap-2 print:hidden" onKeyDown={(e) => { if (e.key === "Escape") { dispatch({ type: "setIsOpen", isOpen: false }); } }} >
{ if (e.key === "Enter") { if (e.shiftKey) { findPrev(); } else { findNext(); } } // Override default browser find (Cmd/Ctrl + G) if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "g") { e.preventDefault(); // Shift for reverse search if (e.shiftKey) { findPrev(); } else { findNext(); } } }} onChange={(e) => { dispatch({ type: "setFind", find: e.target.value }); }} /> { dispatch({ type: "setReplace", replace: e.target.value }); }} />
dispatch({ type: "setCaseSensitive", caseSensitive: pressed, }) } > dispatch({ type: "setWholeWord", wholeWord: pressed }) } > dispatch({ type: "setRegex", regexp: pressed }) } >
{matches != null && currentMatch == null && ( {matches.count} matches )} {matches != null && currentMatch != null && ( {currentMatch + 1} of {matches.count} )}
Press{" "} {" "} again to open the native browser search.
); };