mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-15 13:55:20 +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": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.6.0",
|
"@codemirror/commands": "^6.6.0",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
"@codemirror/search": "^6.5.6",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.33.0",
|
"@codemirror/view": "^6.33.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,39 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { EditorView, keymap, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
|
||||||
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
||||||
import { useTheme } from "next-themes";
|
import { markMatches, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { ZoektMatch } from "@/lib/types";
|
||||||
import CodeMirror from '@uiw/react-codemirror';
|
|
||||||
import { vim } from "@replit/codemirror-vim";
|
|
||||||
import { defaultKeymap } from "@codemirror/commands";
|
import { defaultKeymap } from "@codemirror/commands";
|
||||||
import { javascript } from "@codemirror/lang-javascript";
|
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 { 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 {
|
interface CodePreviewProps {
|
||||||
code: string;
|
file?: CodePreviewFile;
|
||||||
filepath: string;
|
|
||||||
keymapType: "default" | "vim";
|
keymapType: "default" | "vim";
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodePreview = ({
|
export const CodePreview = ({
|
||||||
code,
|
file,
|
||||||
filepath,
|
|
||||||
keymapType,
|
keymapType,
|
||||||
onClose,
|
onClose,
|
||||||
}: CodePreviewProps) => {
|
}: CodePreviewProps) => {
|
||||||
|
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") {
|
||||||
|
|
@ -35,21 +45,27 @@ export const CodePreview = ({
|
||||||
}, [_theme, systemTheme]);
|
}, [_theme, systemTheme]);
|
||||||
|
|
||||||
const [gutterWidth, setGutterWidth] = useState(0);
|
const [gutterWidth, setGutterWidth] = useState(0);
|
||||||
const gutterWidthPlugin = useMemo(() => {
|
|
||||||
return ViewPlugin.fromClass(class {
|
const keymapExtension = useExtensionWithDependency(
|
||||||
width: number = 0;
|
editorRef.current?.view ?? null,
|
||||||
constructor(view: EditorView) {
|
() => {
|
||||||
this.measureWidth(view)
|
switch (keymapType) {
|
||||||
|
case "default":
|
||||||
|
return keymap.of(defaultKeymap);
|
||||||
|
case "vim":
|
||||||
|
return vim();
|
||||||
}
|
}
|
||||||
update(update: ViewUpdate) {
|
},
|
||||||
if (update.geometryChanged) this.measureWidth(update.view)
|
[keymapType]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file || !editorRef.current?.view) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
measureWidth(view: EditorView) {
|
|
||||||
let gutter = view.scrollDOM.querySelector('.cm-gutters') as HTMLElement
|
markMatches(file.matches, editorRef.current.view);
|
||||||
if (gutter) this.width = gutter.offsetWidth
|
}, [file?.matches]);
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|
@ -61,7 +77,7 @@ export const CodePreview = ({
|
||||||
>
|
>
|
||||||
<FileIcon className="h-4 w-4" />
|
<FileIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span>{filepath}</span>
|
<span>{file?.filepath}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||||
<Cross1Icon
|
<Cross1Icon
|
||||||
|
|
@ -72,19 +88,20 @@ export const CodePreview = ({
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-full overflow-y-auto">
|
<ScrollArea className="h-full overflow-y-auto">
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
ref={editorRef}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
value={code}
|
value={file?.content}
|
||||||
theme={theme === "dark" ? "dark" : "light"}
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
extensions={[
|
extensions={[
|
||||||
...(keymapType === "vim" ? [
|
keymapExtension,
|
||||||
vim(),
|
gutterWidthExtension,
|
||||||
] : [
|
|
||||||
keymap.of(defaultKeymap),
|
|
||||||
]),
|
|
||||||
javascript(),
|
javascript(),
|
||||||
gutterWidthPlugin.extension,
|
searchResultHighlightExtension(),
|
||||||
|
search({
|
||||||
|
top: true,
|
||||||
|
}),
|
||||||
EditorView.updateListener.of(update => {
|
EditorView.updateListener.of(update => {
|
||||||
const width = update.view.plugin(gutterWidthPlugin)?.width;
|
const width = update.view.plugin(gutterWidthExtension)?.width;
|
||||||
if (width) {
|
if (width) {
|
||||||
setGutterWidth(width);
|
setGutterWidth(width);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import logoDark from "../../public/sb_logo_dark.png";
|
import logoDark from "../../public/sb_logo_dark.png";
|
||||||
import logoLight from "../../public/sb_logo_light.png";
|
import logoLight from "../../public/sb_logo_light.png";
|
||||||
import { CodePreview } from "./codePreview";
|
import { CodePreview, CodePreviewFile } from "./codePreview";
|
||||||
import { SearchBar } from "./searchBar";
|
import { SearchBar } from "./searchBar";
|
||||||
import { SearchResults } from "./searchResults";
|
import { SearchResults } from "./searchResults";
|
||||||
import { SettingsDropdown } from "./settingsDropdown";
|
import { SettingsDropdown } from "./settingsDropdown";
|
||||||
|
|
@ -30,8 +30,7 @@ export default function Home() {
|
||||||
const [numResults, _setNumResults] = useState(defaultNumResults && !isNaN(Number(defaultNumResults)) ? Number(defaultNumResults) : 100);
|
const [numResults, _setNumResults] = useState(defaultNumResults && !isNaN(Number(defaultNumResults)) ? Number(defaultNumResults) : 100);
|
||||||
|
|
||||||
const [isCodePanelOpen, setIsCodePanelOpen] = useState(false);
|
const [isCodePanelOpen, setIsCodePanelOpen] = useState(false);
|
||||||
const [code, setCode] = useState("");
|
const [previewFile, setPreviewFile] = useState<CodePreviewFile | undefined>(undefined);
|
||||||
const [filepath, setFilepath] = useState("");
|
|
||||||
|
|
||||||
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
|
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -105,9 +104,12 @@ export default function Home() {
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then((body: GetSourceResponse) => {
|
.then((body: GetSourceResponse) => {
|
||||||
|
setPreviewFile({
|
||||||
|
content: body.content,
|
||||||
|
filepath: match.FileName,
|
||||||
|
matches: match.Matches,
|
||||||
|
})
|
||||||
setIsCodePanelOpen(true);
|
setIsCodePanelOpen(true);
|
||||||
setCode(body.content);
|
|
||||||
setFilepath(match.FileName);
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -118,8 +120,7 @@ export default function Home() {
|
||||||
hidden={!isCodePanelOpen}
|
hidden={!isCodePanelOpen}
|
||||||
>
|
>
|
||||||
<CodePreview
|
<CodePreview
|
||||||
code={code}
|
file={previewFile}
|
||||||
filepath={filepath}
|
|
||||||
onClose={() => setIsCodePanelOpen(false)}
|
onClose={() => setIsCodePanelOpen(false)}
|
||||||
keymapType={keymapType}
|
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"
|
"@codemirror/view" "^6.0.0"
|
||||||
crelt "^1.0.5"
|
crelt "^1.0.5"
|
||||||
|
|
||||||
"@codemirror/search@^6.0.0":
|
"@codemirror/search@^6.0.0", "@codemirror/search@^6.5.6":
|
||||||
version "6.5.6"
|
version "6.5.6"
|
||||||
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
|
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
|
||||||
integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==
|
integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue