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 { 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<ReactCodeMirrorRef>(null);
const { theme: _theme, systemTheme } = useTheme();
const theme = useMemo(() => {
if (_theme === "system") {
return systemTheme ?? "light";
@ -59,40 +66,8 @@ export const CodePreview = ({
[keymapType]
);
useEffect(() => {
if (!file || !editorRef.current?.view) {
return;
}
markMatches(file.matches, editorRef.current.view);
}, [file?.matches]);
return (
<div className="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">
<div
style={{ width: `${gutterWidth}px` }}
className="flex justify-center items-center"
>
<FileIcon className="h-4 w-4" />
</div>
<span>{file?.filepath}</span>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Cross1Icon
className="h-4 w-4"
onClick={onClose}
/>
</Button>
</div>
<ScrollArea className="h-full overflow-y-auto">
<CodeMirror
ref={editorRef}
readOnly={true}
value={file?.content}
theme={theme === "dark" ? "dark" : "light"}
extensions={[
const extensions = useMemo(() => {
return [
keymapExtension,
gutterWidthExtension,
javascript(),
@ -105,8 +80,75 @@ export const CodePreview = ({
if (width) {
setGutterWidth(width);
}
})
]}
}),
];
}, [keymapExtension]);
useEffect(() => {
if (!file || !editorRef.current?.view) {
return;
}
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 (
<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">
<div
style={{ width: `${gutterWidth}px` }}
className="flex justify-center items-center"
>
<FileIcon className="h-4 w-4" />
</div>
<span>{file?.filepath}</span>
</div>
<div className="flex flex-row gap-1 items-center">
<p className="text-sm">{`${selectedMatchIndex + 1} of ${file?.matches.length}`}</p>
<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}
>
<Cross1Icon className="h-4 w-4" />
</Button>
</div>
</div>
<ScrollArea className="h-full overflow-auto flex-1">
<CodeMirror
ref={editorRef}
readOnly={true}
value={file?.content}
theme={theme === "dark" ? "dark" : "light"}
extensions={extensions}
/>
<Scrollbar orientation="vertical" />
</ScrollArea>

View file

@ -31,6 +31,7 @@ export default function Home() {
const [isCodePanelOpen, setIsCodePanelOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<CodePreviewFile | undefined>(undefined);
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
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() {
<CodePreview
file={previewFile}
onClose={() => setIsCodePanelOpen(false)}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={setSelectedMatchIndex}
keymapType={keymapType}
/>
</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 { 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<ZoektMatch[]>();
const matchHighlighter = StateField.define<DecorationSet>({
create () {
@ -16,13 +31,13 @@ const matchHighlighter = StateField.define<DecorationSet>({
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<DecorationSet>({
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<ZoektMatch[]> = 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;
}