mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 04:45:19 +00:00
Add basic match highlighting
This commit is contained in:
parent
e287c8e02c
commit
4fc2357fb1
7 changed files with 165 additions and 41 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
27
src/hooks/useExtensionWithDependency.ts
Normal file
27
src/hooks/useExtensionWithDependency.ts
Normal 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;
|
||||
}
|
||||
25
src/lib/extensions/gutterWidthExtension.ts
Normal file
25
src/lib/extensions/gutterWidthExtension.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
53
src/lib/extensions/searchResultHighlightExtension.ts
Normal file
53
src/lib/extensions/searchResultHighlightExtension.ts
Normal 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,
|
||||
]
|
||||
}
|
||||
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Reference in a new issue