mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Add result highlighting to code preview
This commit is contained in:
parent
4fc2357fb1
commit
f85c5cef02
3 changed files with 117 additions and 38 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue