From f85c5cef025645ff8ef37b32ef167cbf1fc2666c Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 29 Aug 2024 21:06:48 -0700 Subject: [PATCH] Add result highlighting to code preview --- src/app/codePreview.tsx | 92 ++++++++++++++----- src/app/page.tsx | 6 +- .../searchResultHighlightExtension.ts | 57 +++++++++--- 3 files changed, 117 insertions(+), 38 deletions(-) diff --git a/src/app/codePreview.tsx b/src/app/codePreview.tsx index 40a5e173..d0850974 100644 --- a/src/app/codePreview.tsx +++ b/src/app/codePreview.tsx @@ -14,8 +14,9 @@ import { Cross1Icon, FileIcon } from "@radix-ui/react-icons"; import { Scrollbar } from "@radix-ui/react-scroll-area"; import { vim } from "@replit/codemirror-vim"; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { ArrowDown, ArrowUp } from "lucide-react"; import { useTheme } from "next-themes"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export interface CodePreviewFile { content: string; @@ -26,16 +27,22 @@ export interface CodePreviewFile { interface CodePreviewProps { file?: CodePreviewFile; keymapType: "default" | "vim"; + selectedMatchIndex: number; + onSelectedMatchIndexChange: (index: number) => void; onClose: () => void; } export const CodePreview = ({ file, keymapType, + selectedMatchIndex, + onSelectedMatchIndexChange, onClose, }: CodePreviewProps) => { const editorRef = useRef(null); + const { theme: _theme, systemTheme } = useTheme(); + const theme = useMemo(() => { if (_theme === "system") { return systemTheme ?? "light"; @@ -59,16 +66,42 @@ export const CodePreview = ({ [keymapType] ); + const extensions = useMemo(() => { + return [ + keymapExtension, + gutterWidthExtension, + javascript(), + searchResultHighlightExtension(), + search({ + top: true, + }), + EditorView.updateListener.of(update => { + const width = update.view.plugin(gutterWidthExtension)?.width; + if (width) { + setGutterWidth(width); + } + }), + ]; + }, [keymapExtension]); + useEffect(() => { if (!file || !editorRef.current?.view) { return; } - markMatches(file.matches, editorRef.current.view); - }, [file?.matches]); + markMatches(selectedMatchIndex, file.matches, editorRef.current.view); + }, [file?.matches, selectedMatchIndex]); + + const onUpClicked = useCallback(() => { + onSelectedMatchIndexChange(selectedMatchIndex - 1); + }, [selectedMatchIndex]); + + const onDownClicked = useCallback(() => { + onSelectedMatchIndexChange(selectedMatchIndex + 1); + }, [selectedMatchIndex]); return ( -
+
{file?.filepath}
- + + + > + + +
- + { - const width = update.view.plugin(gutterWidthExtension)?.width; - if (width) { - setGutterWidth(width); - } - }) - ]} + extensions={extensions} /> diff --git a/src/app/page.tsx b/src/app/page.tsx index 76e4eee0..22c4002b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,6 +31,7 @@ export default function Home() { const [isCodePanelOpen, setIsCodePanelOpen] = useState(false); const [previewFile, setPreviewFile] = useState(undefined); + const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); const [fileMatches, setFileMatches] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -104,11 +105,12 @@ export default function Home() { fetch(url) .then(response => response.json()) .then((body: GetSourceResponse) => { + setSelectedMatchIndex(0); setPreviewFile({ content: body.content, filepath: match.FileName, matches: match.Matches, - }) + }); setIsCodePanelOpen(true); }); }} @@ -122,6 +124,8 @@ export default function Home() { setIsCodePanelOpen(false)} + selectedMatchIndex={selectedMatchIndex} + onSelectedMatchIndexChange={setSelectedMatchIndex} keymapType={keymapType} /> diff --git a/src/lib/extensions/searchResultHighlightExtension.ts b/src/lib/extensions/searchResultHighlightExtension.ts index 164b1637..696043a3 100644 --- a/src/lib/extensions/searchResultHighlightExtension.ts +++ b/src/lib/extensions/searchResultHighlightExtension.ts @@ -1,12 +1,27 @@ -import { Extension, StateEffect, StateField, Transaction } from "@codemirror/state"; +import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; import { ZoektMatch } from "../types"; const matchMark = Decoration.mark({ class: "cm-searchMatch" }); +const selectedMatchMark = Decoration.mark({ + class: "cm-searchMatch cm-searchMatch-selected" +}); + +const setMatchState = StateEffect.define<{ + selectedMatchIndex: number, + matches: ZoektMatch[], +}>(); + +const getMatchRange = (match: ZoektMatch, document: Text) => { + const line = document.line(match.LineNum); + const fragment = match.Fragments[0]; + const from = line.from + fragment.Pre.length; + const to = from + fragment.Match.length; + return { from, to }; +} -const setMatches = StateEffect.define(); const matchHighlighter = StateField.define({ create () { @@ -16,13 +31,13 @@ const matchHighlighter = StateField.define({ highlights = highlights.map(transaction.changes); for (const effect of transaction.effects) { - if (effect.is(setMatches)) { - const decorations = effect.value.map(match => { - const line = transaction.newDoc.line(match.LineNum); - const fragment = match.Fragments[0]; - const from = line.from + fragment.Pre.length; - const to = from + fragment.Match.length; - return matchMark.range(from, to); + if (effect.is(setMatchState)) { + const { matches, selectedMatchIndex } = effect.value; + + const decorations = matches.map((match, index) => { + const { from, to } = getMatchRange(match, transaction.newDoc); + const mark = index === selectedMatchIndex ? selectedMatchMark : matchMark; + return mark.range(from, to); }); highlights = Decoration.set(decorations) @@ -37,11 +52,29 @@ const matchHighlighter = StateField.define({ const highlightTheme = EditorView.baseTheme({ "&light .cm-searchMatch": { backgroundColor: "#ffff0054" }, "&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" }, + "&light .cm-searchMatch-selected": { backgroundColor: "#ff6a0054" }, + "&dark .cm-searchMatch-selected": { backgroundColor: "#ff00ff8a" } }); -export const markMatches = (matches: ZoektMatch[], view: EditorView) => { - const effect: StateEffect = setMatches.of(matches); - view.dispatch({ effects: [effect] }); +export const markMatches = (selectedMatchIndex: number, matches: ZoektMatch[], view: EditorView) => { + const setState = setMatchState.of({ + selectedMatchIndex, + matches, + }); + + const effects = [] + effects.push(setState); + + if (selectedMatchIndex >= 0 && selectedMatchIndex < matches.length) { + const match = matches[selectedMatchIndex]; + const { from, to } = getMatchRange(match, view.state.doc); + const selection = EditorSelection.range(from, to); + effects.push(EditorView.scrollIntoView(selection, { + y: "start", + })); + }; + + view.dispatch({ effects }); return true; }