Add basic match highlighting

This commit is contained in:
bkellam 2024-08-29 17:47:35 -07:00
parent e287c8e02c
commit 4fc2357fb1
7 changed files with 165 additions and 41 deletions

View file

@ -11,6 +11,7 @@
"dependencies": {
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.33.0",
"@radix-ui/react-dropdown-menu": "^2.1.1",

View file

@ -2,29 +2,39 @@
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { EditorView, keymap, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useState } from "react";
import CodeMirror from '@uiw/react-codemirror';
import { vim } from "@replit/codemirror-vim";
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
import { markMatches, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
import { ZoektMatch } from "@/lib/types";
import { defaultKeymap } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { search } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
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 { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
export interface CodePreviewFile {
content: string;
filepath: string;
matches: ZoektMatch[];
}
interface CodePreviewProps {
code: string;
filepath: string;
file?: CodePreviewFile;
keymapType: "default" | "vim";
onClose: () => void;
}
export const CodePreview = ({
code,
filepath,
file,
keymapType,
onClose,
}: CodePreviewProps) => {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const { theme: _theme, systemTheme } = useTheme();
const theme = useMemo(() => {
if (_theme === "system") {
@ -35,21 +45,27 @@ export const CodePreview = ({
}, [_theme, systemTheme]);
const [gutterWidth, setGutterWidth] = useState(0);
const gutterWidthPlugin = useMemo(() => {
return ViewPlugin.fromClass(class {
width: number = 0;
constructor(view: EditorView) {
this.measureWidth(view)
const keymapExtension = useExtensionWithDependency(
editorRef.current?.view ?? null,
() => {
switch (keymapType) {
case "default":
return keymap.of(defaultKeymap);
case "vim":
return vim();
}
update(update: ViewUpdate) {
if (update.geometryChanged) this.measureWidth(update.view)
}
measureWidth(view: EditorView) {
let gutter = view.scrollDOM.querySelector('.cm-gutters') as HTMLElement
if (gutter) this.width = gutter.offsetWidth
}
});
}, []);
},
[keymapType]
);
useEffect(() => {
if (!file || !editorRef.current?.view) {
return;
}
markMatches(file.matches, editorRef.current.view);
}, [file?.matches]);
return (
<div className="h-full">
@ -61,7 +77,7 @@ export const CodePreview = ({
>
<FileIcon className="h-4 w-4" />
</div>
<span>{filepath}</span>
<span>{file?.filepath}</span>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Cross1Icon
@ -72,19 +88,20 @@ export const CodePreview = ({
</div>
<ScrollArea className="h-full overflow-y-auto">
<CodeMirror
ref={editorRef}
readOnly={true}
value={code}
value={file?.content}
theme={theme === "dark" ? "dark" : "light"}
extensions={[
...(keymapType === "vim" ? [
vim(),
] : [
keymap.of(defaultKeymap),
]),
keymapExtension,
gutterWidthExtension,
javascript(),
gutterWidthPlugin.extension,
searchResultHighlightExtension(),
search({
top: true,
}),
EditorView.updateListener.of(update => {
const width = update.view.plugin(gutterWidthPlugin)?.width;
const width = update.view.plugin(gutterWidthExtension)?.width;
if (width) {
setGutterWidth(width);
}

View file

@ -15,7 +15,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import logoDark from "../../public/sb_logo_dark.png";
import logoLight from "../../public/sb_logo_light.png";
import { CodePreview } from "./codePreview";
import { CodePreview, CodePreviewFile } from "./codePreview";
import { SearchBar } from "./searchBar";
import { SearchResults } from "./searchResults";
import { SettingsDropdown } from "./settingsDropdown";
@ -30,8 +30,7 @@ export default function Home() {
const [numResults, _setNumResults] = useState(defaultNumResults && !isNaN(Number(defaultNumResults)) ? Number(defaultNumResults) : 100);
const [isCodePanelOpen, setIsCodePanelOpen] = useState(false);
const [code, setCode] = useState("");
const [filepath, setFilepath] = useState("");
const [previewFile, setPreviewFile] = useState<CodePreviewFile | undefined>(undefined);
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
const [isLoading, setIsLoading] = useState(false);
@ -105,9 +104,12 @@ export default function Home() {
fetch(url)
.then(response => response.json())
.then((body: GetSourceResponse) => {
setPreviewFile({
content: body.content,
filepath: match.FileName,
matches: match.Matches,
})
setIsCodePanelOpen(true);
setCode(body.content);
setFilepath(match.FileName);
});
}}
/>
@ -118,8 +120,7 @@ export default function Home() {
hidden={!isCodePanelOpen}
>
<CodePreview
code={code}
filepath={filepath}
file={previewFile}
onClose={() => setIsCodePanelOpen(false)}
keymapType={keymapType}
/>

View file

@ -0,0 +1,27 @@
'use client';
import { Compartment, Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { useEffect, useMemo } from "react";
/**
* @see https://thetrevorharmon.com/blog/codemirror-and-react/
*/
export function useExtensionWithDependency(
view: EditorView | null,
extensionFactory: () => Extension,
deps: any[],
) {
const compartment = useMemo(() => new Compartment(), []);
const extension = useMemo(() => compartment.of(extensionFactory()), []);
useEffect(() => {
if (view) {
view.dispatch({
effects: compartment.reconfigure(extensionFactory()),
});
}
}, deps);
return extension;
}

View file

@ -0,0 +1,25 @@
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"
/**
* Measures the width of the gutter and stores it in the plugin instance.
*/
export const gutterWidthExtension = ViewPlugin.fromClass(class {
width: number = 0;
constructor (view: EditorView) {
this.measureWidth(view);
}
update = (update: ViewUpdate) => {
if (update.geometryChanged) {
this.measureWidth(update.view);
}
}
measureWidth = (view: EditorView) => {
let gutter = view.scrollDOM.querySelector('.cm-gutters') as HTMLElement;
if (gutter) {
this.width = gutter.offsetWidth;
}
}
});

View file

@ -0,0 +1,53 @@
import { Extension, StateEffect, StateField, Transaction } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
import { ZoektMatch } from "../types";
const matchMark = Decoration.mark({
class: "cm-searchMatch"
});
const setMatches = StateEffect.define<ZoektMatch[]>();
const matchHighlighter = StateField.define<DecorationSet>({
create () {
return Decoration.none;
},
update (highlights: DecorationSet, transaction: Transaction) {
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);
});
highlights = Decoration.set(decorations)
}
}
return highlights;
},
provide: (field) => EditorView.decorations.from(field),
});
const highlightTheme = EditorView.baseTheme({
"&light .cm-searchMatch": { backgroundColor: "#ffff0054" },
"&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" },
});
export const markMatches = (matches: ZoektMatch[], view: EditorView) => {
const effect: StateEffect<ZoektMatch[]> = setMatches.of(matches);
view.dispatch({ effects: [effect] });
return true;
}
export const searchResultHighlightExtension = (): Extension => {
return [
highlightTheme,
matchHighlighter,
]
}

View file

@ -68,7 +68,7 @@
"@codemirror/view" "^6.0.0"
crelt "^1.0.5"
"@codemirror/search@^6.0.0":
"@codemirror/search@^6.0.0", "@codemirror/search@^6.5.6":
version "6.5.6"
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==