Add result highlighting to code preview

This commit is contained in:
bkellam 2024-08-29 21:06:48 -07:00
parent 4fc2357fb1
commit f85c5cef02
3 changed files with 117 additions and 38 deletions

View file

@ -14,8 +14,9 @@ import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
import { Scrollbar } from "@radix-ui/react-scroll-area"; import { Scrollbar } from "@radix-ui/react-scroll-area";
import { vim } from "@replit/codemirror-vim"; import { vim } from "@replit/codemirror-vim";
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { ArrowDown, ArrowUp } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export interface CodePreviewFile { export interface CodePreviewFile {
content: string; content: string;
@ -26,16 +27,22 @@ export interface CodePreviewFile {
interface CodePreviewProps { interface CodePreviewProps {
file?: CodePreviewFile; file?: CodePreviewFile;
keymapType: "default" | "vim"; keymapType: "default" | "vim";
selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void;
onClose: () => void; onClose: () => void;
} }
export const CodePreview = ({ export const CodePreview = ({
file, file,
keymapType, keymapType,
selectedMatchIndex,
onSelectedMatchIndexChange,
onClose, onClose,
}: CodePreviewProps) => { }: CodePreviewProps) => {
const editorRef = useRef<ReactCodeMirrorRef>(null); const editorRef = useRef<ReactCodeMirrorRef>(null);
const { theme: _theme, systemTheme } = useTheme(); const { theme: _theme, systemTheme } = useTheme();
const theme = useMemo(() => { const theme = useMemo(() => {
if (_theme === "system") { if (_theme === "system") {
return systemTheme ?? "light"; return systemTheme ?? "light";
@ -59,16 +66,42 @@ export const CodePreview = ({
[keymapType] [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(() => { useEffect(() => {
if (!file || !editorRef.current?.view) { if (!file || !editorRef.current?.view) {
return; return;
} }
markMatches(file.matches, editorRef.current.view); markMatches(selectedMatchIndex, file.matches, editorRef.current.view);
}, [file?.matches]); }, [file?.matches, selectedMatchIndex]);
const onUpClicked = useCallback(() => {
onSelectedMatchIndexChange(selectedMatchIndex - 1);
}, [selectedMatchIndex]);
const onDownClicked = useCallback(() => {
onSelectedMatchIndexChange(selectedMatchIndex + 1);
}, [selectedMatchIndex]);
return ( return (
<div className="h-full"> <div className="flex flex-col h-full">
<div className="flex flex-row bg-cyan-200 dark:bg-cyan-900 items-center justify-between pr-3"> <div className="flex flex-row bg-cyan-200 dark:bg-cyan-900 items-center justify-between pr-3">
<div className="flex flex-row"> <div className="flex flex-row">
<div <div
@ -79,34 +112,43 @@ export const CodePreview = ({
</div> </div>
<span>{file?.filepath}</span> <span>{file?.filepath}</span>
</div> </div>
<Button variant="ghost" size="icon" className="h-6 w-6"> <div className="flex flex-row gap-1 items-center">
<Cross1Icon <p className="text-sm">{`${selectedMatchIndex + 1} of ${file?.matches.length}`}</p>
className="h-4 w-4" <Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={selectedMatchIndex === 0}
onClick={onUpClicked}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onDownClicked}
disabled={file ? selectedMatchIndex === file?.matches.length - 1 : true}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onClose} onClick={onClose}
/> >
</Button> <Cross1Icon className="h-4 w-4" />
</Button>
</div>
</div> </div>
<ScrollArea className="h-full overflow-y-auto"> <ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror <CodeMirror
ref={editorRef} ref={editorRef}
readOnly={true} readOnly={true}
value={file?.content} value={file?.content}
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
extensions={[ extensions={extensions}
keymapExtension,
gutterWidthExtension,
javascript(),
searchResultHighlightExtension(),
search({
top: true,
}),
EditorView.updateListener.of(update => {
const width = update.view.plugin(gutterWidthExtension)?.width;
if (width) {
setGutterWidth(width);
}
})
]}
/> />
<Scrollbar orientation="vertical" /> <Scrollbar orientation="vertical" />
</ScrollArea> </ScrollArea>

View file

@ -31,6 +31,7 @@ export default function Home() {
const [isCodePanelOpen, setIsCodePanelOpen] = useState(false); const [isCodePanelOpen, setIsCodePanelOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<CodePreviewFile | undefined>(undefined); const [previewFile, setPreviewFile] = useState<CodePreviewFile | undefined>(undefined);
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]); const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -104,11 +105,12 @@ export default function Home() {
fetch(url) fetch(url)
.then(response => response.json()) .then(response => response.json())
.then((body: GetSourceResponse) => { .then((body: GetSourceResponse) => {
setSelectedMatchIndex(0);
setPreviewFile({ setPreviewFile({
content: body.content, content: body.content,
filepath: match.FileName, filepath: match.FileName,
matches: match.Matches, matches: match.Matches,
}) });
setIsCodePanelOpen(true); setIsCodePanelOpen(true);
}); });
}} }}
@ -122,6 +124,8 @@ export default function Home() {
<CodePreview <CodePreview
file={previewFile} file={previewFile}
onClose={() => setIsCodePanelOpen(false)} onClose={() => setIsCodePanelOpen(false)}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={setSelectedMatchIndex}
keymapType={keymapType} keymapType={keymapType}
/> />
</ResizablePanel> </ResizablePanel>

View file

@ -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 { Decoration, DecorationSet, EditorView } from "@codemirror/view";
import { ZoektMatch } from "../types"; import { ZoektMatch } from "../types";
const matchMark = Decoration.mark({ const matchMark = Decoration.mark({
class: "cm-searchMatch" 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<ZoektMatch[]>();
const matchHighlighter = StateField.define<DecorationSet>({ const matchHighlighter = StateField.define<DecorationSet>({
create () { create () {
@ -16,13 +31,13 @@ const matchHighlighter = StateField.define<DecorationSet>({
highlights = highlights.map(transaction.changes); highlights = highlights.map(transaction.changes);
for (const effect of transaction.effects) { for (const effect of transaction.effects) {
if (effect.is(setMatches)) { if (effect.is(setMatchState)) {
const decorations = effect.value.map(match => { const { matches, selectedMatchIndex } = effect.value;
const line = transaction.newDoc.line(match.LineNum);
const fragment = match.Fragments[0]; const decorations = matches.map((match, index) => {
const from = line.from + fragment.Pre.length; const { from, to } = getMatchRange(match, transaction.newDoc);
const to = from + fragment.Match.length; const mark = index === selectedMatchIndex ? selectedMatchMark : matchMark;
return matchMark.range(from, to); return mark.range(from, to);
}); });
highlights = Decoration.set(decorations) highlights = Decoration.set(decorations)
@ -37,11 +52,29 @@ const matchHighlighter = StateField.define<DecorationSet>({
const highlightTheme = EditorView.baseTheme({ const highlightTheme = EditorView.baseTheme({
"&light .cm-searchMatch": { backgroundColor: "#ffff0054" }, "&light .cm-searchMatch": { backgroundColor: "#ffff0054" },
"&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" }, "&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" },
"&light .cm-searchMatch-selected": { backgroundColor: "#ff6a0054" },
"&dark .cm-searchMatch-selected": { backgroundColor: "#ff00ff8a" }
}); });
export const markMatches = (matches: ZoektMatch[], view: EditorView) => { export const markMatches = (selectedMatchIndex: number, matches: ZoektMatch[], view: EditorView) => {
const effect: StateEffect<ZoektMatch[]> = setMatches.of(matches); const setState = setMatchState.of({
view.dispatch({ effects: [effect] }); 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; return true;
} }