mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 13:25:21 +00:00
Hotkeys, cleanup, and UX improvements (#9)
This commit is contained in:
parent
a51a5e2764
commit
afede30de6
15 changed files with 512 additions and 440 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { FileSourceResponse, fileSourceResponseSchema, ListRepositoriesResponse, listRepositoriesResponseSchema, SearchRequest, SearchResponse, searchResponseSchema } from "@/lib/schemas";
|
||||
import { fileSourceResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas";
|
||||
import { FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
|
||||
|
||||
export const search = async (body: SearchRequest): Promise<SearchResponse> => {
|
||||
const result = await fetch(`/api/search`, {
|
||||
|
|
|
|||
|
|
@ -66,4 +66,23 @@
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutters {
|
||||
background-color: transparent;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.cm-editor .cm-lineNumbers .cm-gutterElement {
|
||||
padding-left: 0.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cm-editor .cm-searchMatch {
|
||||
border: dotted;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cm-editor .cm-searchMatch-selected {
|
||||
border: solid;
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { Repository } from "@/lib/schemas";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
|
|
@ -11,6 +10,7 @@ import { getRepoCodeHostInfo } from "@/lib/utils";
|
|||
import Image from "next/image";
|
||||
import { FileIcon } from "@radix-ui/react-icons";
|
||||
import clsx from "clsx";
|
||||
import { Repository } from "@/lib/types";
|
||||
|
||||
interface RepositoryCarouselProps {
|
||||
repos: Repository[];
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExt
|
|||
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
|
||||
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
||||
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
||||
import { SearchResultFileMatch } from "@/lib/schemas";
|
||||
import { SearchResultFileMatch } from "@/lib/types";
|
||||
import { defaultKeymap } from "@codemirror/commands";
|
||||
import { search } from "@codemirror/search";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
|
|
@ -28,19 +28,19 @@ export interface CodePreviewFile {
|
|||
language: string;
|
||||
}
|
||||
|
||||
interface CodePreviewPanelProps {
|
||||
interface CodePreviewProps {
|
||||
file?: CodePreviewFile;
|
||||
selectedMatchIndex: number;
|
||||
onSelectedMatchIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CodePreviewPanel = ({
|
||||
export const CodePreview = ({
|
||||
file,
|
||||
selectedMatchIndex,
|
||||
onSelectedMatchIndexChange,
|
||||
onClose,
|
||||
}: CodePreviewPanelProps) => {
|
||||
}: CodePreviewProps) => {
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
const [ keymapType ] = useKeymapType();
|
||||
|
|
@ -67,6 +67,7 @@ export const CodePreviewPanel = ({
|
|||
keymapExtension,
|
||||
gutterWidthExtension,
|
||||
syntaxHighlighting,
|
||||
EditorView.lineWrapping,
|
||||
searchResultHighlightExtension(),
|
||||
search({
|
||||
top: true,
|
||||
63
src/app/search/components/codePreviewPanel/index.tsx
Normal file
63
src/app/search/components/codePreviewPanel/index.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client';
|
||||
|
||||
import { fetchFileSource } from "@/app/api/(client)/client";
|
||||
import { getCodeHostFilePreviewLink } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CodePreview, CodePreviewFile } from "./codePreview";
|
||||
import { SearchResultFile } from "@/lib/types";
|
||||
|
||||
interface CodePreviewPanelProps {
|
||||
fileMatch?: SearchResultFile;
|
||||
onClose: () => void;
|
||||
selectedMatchIndex: number;
|
||||
onSelectedMatchIndexChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export const CodePreviewPanel = ({
|
||||
fileMatch,
|
||||
onClose,
|
||||
selectedMatchIndex,
|
||||
onSelectedMatchIndexChange,
|
||||
}: CodePreviewPanelProps) => {
|
||||
|
||||
const { data: file } = useQuery({
|
||||
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository],
|
||||
queryFn: async (): Promise<CodePreviewFile | undefined> => {
|
||||
if (!fileMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fetchFileSource(fileMatch.FileName, fileMatch.Repository)
|
||||
.then(({ source }) => {
|
||||
// @todo : refector this to use the templates provided by zoekt.
|
||||
const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName)
|
||||
|
||||
const decodedSource = atob(source);
|
||||
|
||||
// Filter out filename matches
|
||||
const filteredMatches = fileMatch.ChunkMatches.filter((match) => {
|
||||
return !match.FileName;
|
||||
});
|
||||
|
||||
return {
|
||||
content: decodedSource,
|
||||
filepath: fileMatch.FileName,
|
||||
matches: filteredMatches,
|
||||
link: link,
|
||||
language: fileMatch.Language,
|
||||
};
|
||||
});
|
||||
},
|
||||
enabled: fileMatch !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<CodePreview
|
||||
file={file}
|
||||
onClose={onClose}
|
||||
selectedMatchIndex={selectedMatchIndex}
|
||||
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
125
src/app/search/components/searchResultsPanel/codePreview.tsx
Normal file
125
src/app/search/components/searchResultsPanel/codePreview.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
'use client';
|
||||
|
||||
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
|
||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
||||
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
|
||||
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
||||
import { SearchResultRange } from "@/lib/types";
|
||||
import CodeMirror, { Decoration, DecorationSet, EditorState, EditorView, ReactCodeMirrorRef, StateField, Transaction } from "@uiw/react-codemirror";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
const markDecoration = Decoration.mark({
|
||||
class: "cm-searchMatch-selected"
|
||||
});
|
||||
|
||||
interface CodePreviewProps {
|
||||
content: string,
|
||||
language: string,
|
||||
ranges: SearchResultRange[],
|
||||
lineOffset: number,
|
||||
}
|
||||
|
||||
export const CodePreview = ({
|
||||
content,
|
||||
language,
|
||||
ranges,
|
||||
lineOffset,
|
||||
}: CodePreviewProps) => {
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const { theme } = useThemeNormalized();
|
||||
|
||||
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
|
||||
|
||||
const rangeHighlighting = useExtensionWithDependency(editorRef.current?.view ?? null, () => {
|
||||
return [
|
||||
StateField.define<DecorationSet>({
|
||||
create(editorState: EditorState) {
|
||||
const document = editorState.doc;
|
||||
|
||||
const decorations = ranges
|
||||
.sort((a, b) => {
|
||||
return a.Start.ByteOffset - b.Start.ByteOffset;
|
||||
})
|
||||
.filter(({ Start, End }) => {
|
||||
const startLine = Start.LineNumber - lineOffset;
|
||||
const endLine = End.LineNumber - lineOffset;
|
||||
|
||||
if (
|
||||
startLine < 1 ||
|
||||
endLine < 1 ||
|
||||
startLine > document.lines ||
|
||||
endLine > document.lines
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(({ Start, End }) => {
|
||||
const startLine = Start.LineNumber - lineOffset;
|
||||
const endLine = End.LineNumber - lineOffset;
|
||||
|
||||
const from = document.line(startLine).from + Start.Column - 1;
|
||||
const to = document.line(endLine).from + End.Column - 1;
|
||||
return markDecoration.range(from, to);
|
||||
});
|
||||
|
||||
return Decoration.set(decorations);
|
||||
},
|
||||
update(highlights: DecorationSet, _transaction: Transaction) {
|
||||
return highlights;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
}),
|
||||
];
|
||||
}, [ranges, lineOffset]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
return [
|
||||
syntaxHighlighting,
|
||||
EditorView.lineWrapping,
|
||||
lineOffsetExtension(lineOffset),
|
||||
rangeHighlighting,
|
||||
];
|
||||
}, [syntaxHighlighting, lineOffset, rangeHighlighting]);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
readOnly={true}
|
||||
editable={false}
|
||||
value={content}
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
syntaxHighlighting: true,
|
||||
|
||||
// Disable all this other stuff...
|
||||
... {
|
||||
foldGutter: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSpecialChars: false,
|
||||
history: false,
|
||||
drawSelection: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: false,
|
||||
closeBracketsKeymap: false,
|
||||
defaultKeymap: false,
|
||||
searchKeymap: false,
|
||||
historyKeymap: false,
|
||||
foldKeymap: false,
|
||||
completionKeymap: false,
|
||||
lintKeymap: false,
|
||||
}
|
||||
}}
|
||||
extensions={extensions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
48
src/app/search/components/searchResultsPanel/fileMatch.tsx
Normal file
48
src/app/search/components/searchResultsPanel/fileMatch.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { CodePreview } from "./codePreview";
|
||||
import { SearchResultFile, SearchResultFileMatch } from "@/lib/types";
|
||||
|
||||
|
||||
interface FileMatchProps {
|
||||
match: SearchResultFileMatch;
|
||||
file: SearchResultFile;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export const FileMatch = ({
|
||||
match,
|
||||
file,
|
||||
onOpen,
|
||||
}: FileMatchProps) => {
|
||||
const content = useMemo(() => {
|
||||
return atob(match.Content);
|
||||
}, [match.Content]);
|
||||
|
||||
// If it's just the title, don't show a code preview
|
||||
if (match.FileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="cursor-pointer p-1 focus:ring-inset focus:ring-4 bg-white dark:bg-[#282c34]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
onOpen();
|
||||
}}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<CodePreview
|
||||
content={content}
|
||||
language={file.Language}
|
||||
ranges={match.Ranges}
|
||||
lineOffset={match.ContentStart.LineNumber - 1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
'use client';
|
||||
|
||||
import { getRepoCodeHostInfo } from "@/lib/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
|
||||
import clsx from "clsx";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SearchResultFile } from "@/lib/types";
|
||||
import { FileMatch } from "./fileMatch";
|
||||
|
||||
const MAX_MATCHES_TO_PREVIEW = 3;
|
||||
|
||||
interface FileMatchContainerProps {
|
||||
file: SearchResultFile;
|
||||
onOpenFile: () => void;
|
||||
onMatchIndexChanged: (matchIndex: number) => void;
|
||||
}
|
||||
|
||||
export const FileMatchContainer = ({
|
||||
file,
|
||||
onOpenFile,
|
||||
onMatchIndexChanged,
|
||||
}: FileMatchContainerProps) => {
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const matchCount = useMemo(() => {
|
||||
return file.ChunkMatches.length;
|
||||
}, [file]);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const sortedMatches = file.ChunkMatches.sort((a, b) => {
|
||||
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
|
||||
});
|
||||
|
||||
if (!showAll) {
|
||||
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
|
||||
}
|
||||
|
||||
return sortedMatches;
|
||||
}, [file, showAll]);
|
||||
|
||||
const fileNameRange = useMemo(() => {
|
||||
for (const match of matches) {
|
||||
if (match.FileName && match.Ranges.length > 0) {
|
||||
const range = match.Ranges[0];
|
||||
return {
|
||||
from: range.Start.Column - 1,
|
||||
to: range.End.Column - 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [matches]);
|
||||
|
||||
const { repoIcon, repoName, repoLink } = useMemo(() => {
|
||||
const info = getRepoCodeHostInfo(file.Repository);
|
||||
if (info) {
|
||||
return {
|
||||
repoName: info.repoName,
|
||||
repoLink: info.repoLink,
|
||||
repoIcon: <Image
|
||||
src={info.icon}
|
||||
alt={info.costHostName}
|
||||
className="w-4 h-4 dark:invert"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repoName: file.Repository,
|
||||
repoLink: undefined,
|
||||
repoIcon: <FileIcon className="w-4 h-4" />
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const isMoreContentButtonVisible = useMemo(() => {
|
||||
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
||||
}, [matchCount]);
|
||||
|
||||
const onShowMoreMatches = useCallback(() => {
|
||||
setShowAll(!showAll);
|
||||
}, [showAll]);
|
||||
|
||||
const onOpenMatch = useCallback((index: number) => {
|
||||
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
||||
return acc + match.Ranges.length;
|
||||
}, 0);
|
||||
onOpenFile();
|
||||
onMatchIndexChanged(matchIndex);
|
||||
}, [matches, onMatchIndexChanged, onOpenFile]);
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="sticky top-0 bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between border cursor-pointer z-10"
|
||||
onClick={() => {
|
||||
onOpenFile();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{repoIcon}
|
||||
<span
|
||||
className={clsx("font-medium", {
|
||||
"cursor-pointer hover:underline": repoLink,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (repoLink) {
|
||||
window.open(repoLink, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{repoName}
|
||||
</span>
|
||||
<span>·</span>
|
||||
{!fileNameRange ? (
|
||||
<span>{file.FileName}</span>
|
||||
) : (
|
||||
<span>
|
||||
{file.FileName.slice(0, fileNameRange.from)}
|
||||
<span className="bg-yellow-200 dark:bg-blue-700">
|
||||
{file.FileName.slice(fileNameRange.from, fileNameRange.to)}
|
||||
</span>
|
||||
{file.FileName.slice(fileNameRange.to)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{matches.map((match, index) => (
|
||||
<div
|
||||
key={index}
|
||||
>
|
||||
<FileMatch
|
||||
match={match}
|
||||
file={file}
|
||||
onOpen={() => {
|
||||
onOpenMatch(index);
|
||||
}}
|
||||
/>
|
||||
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
|
||||
<Separator className="dark:bg-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isMoreContentButtonVisible && (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="px-4 bg-accent p-0.5"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
onShowMoreMatches();
|
||||
}}
|
||||
onClick={onShowMoreMatches}
|
||||
>
|
||||
<p
|
||||
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
|
||||
>
|
||||
{showAll ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
|
||||
{showAll ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/search/components/searchResultsPanel/index.tsx
Normal file
47
src/app/search/components/searchResultsPanel/index.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use client';
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
||||
import { FileMatchContainer } from "./fileMatchContainer";
|
||||
import { SearchResultFile } from "@/lib/types";
|
||||
|
||||
interface SearchResultsPanelProps {
|
||||
fileMatches: SearchResultFile[];
|
||||
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
|
||||
onMatchIndexChanged: (matchIndex: number) => void;
|
||||
}
|
||||
|
||||
export const SearchResultsPanel = ({
|
||||
fileMatches,
|
||||
onOpenFileMatch,
|
||||
onMatchIndexChanged,
|
||||
}: SearchResultsPanelProps) => {
|
||||
|
||||
if (fileMatches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
>
|
||||
{fileMatches.map((fileMatch, index) => (
|
||||
<FileMatchContainer
|
||||
key={index}
|
||||
file={fileMatch}
|
||||
onOpenFile={() => {
|
||||
onOpenFileMatch(fileMatch);
|
||||
}}
|
||||
onMatchIndexChanged={(matchIndex) => {
|
||||
onMatchIndexChanged(matchIndex);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Scrollbar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,8 +7,7 @@ import {
|
|||
} from "@/components/ui/resizable";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||
import { SearchResultFile } from "@/lib/schemas";
|
||||
import { createPathWithQueryParams, getCodeHostFilePreviewLink } from "@/lib/utils";
|
||||
import { createPathWithQueryParams } from "@/lib/utils";
|
||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Image from "next/image";
|
||||
|
|
@ -16,12 +15,13 @@ import { useRouter } from "next/navigation";
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import logoDark from "../../../public/sb_logo_dark.png";
|
||||
import logoLight from "../../../public/sb_logo_light.png";
|
||||
import { fetchFileSource, search } from "../api/(client)/client";
|
||||
import { search } from "../api/(client)/client";
|
||||
import { SearchBar } from "../searchBar";
|
||||
import { SettingsDropdown } from "../settingsDropdown";
|
||||
import { CodePreviewFile, CodePreviewPanel } from "./codePreviewPanel";
|
||||
import { SearchResultsPanel } from "./searchResultsPanel";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||
import { SearchResultFile } from "@/lib/types";
|
||||
|
||||
const DEFAULT_NUM_RESULTS = 100;
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ export default function SearchPage() {
|
|||
minSize={20}
|
||||
hidden={!selectedFile}
|
||||
>
|
||||
<CodePreviewWrapper
|
||||
<CodePreviewPanel
|
||||
fileMatch={selectedFile}
|
||||
onClose={() => setSelectedFile(undefined)}
|
||||
selectedMatchIndex={selectedMatchIndex}
|
||||
|
|
@ -185,59 +185,3 @@ export default function SearchPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodePreviewWrapperProps {
|
||||
fileMatch?: SearchResultFile;
|
||||
onClose: () => void;
|
||||
selectedMatchIndex: number;
|
||||
onSelectedMatchIndexChange: (index: number) => void;
|
||||
}
|
||||
|
||||
const CodePreviewWrapper = ({
|
||||
fileMatch,
|
||||
onClose,
|
||||
selectedMatchIndex,
|
||||
onSelectedMatchIndexChange,
|
||||
}: CodePreviewWrapperProps) => {
|
||||
|
||||
const { data: file } = useQuery({
|
||||
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository],
|
||||
queryFn: async (): Promise<CodePreviewFile | undefined> => {
|
||||
if (!fileMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fetchFileSource(fileMatch.FileName, fileMatch.Repository)
|
||||
.then(({ source }) => {
|
||||
// @todo : refector this to use the templates provided by zoekt.
|
||||
const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName)
|
||||
|
||||
const decodedSource = atob(source);
|
||||
|
||||
// Filter out filename matches
|
||||
const filteredMatches = fileMatch.ChunkMatches.filter((match) => {
|
||||
return !match.FileName;
|
||||
});
|
||||
|
||||
return {
|
||||
content: decodedSource,
|
||||
filepath: fileMatch.FileName,
|
||||
matches: filteredMatches,
|
||||
link: link,
|
||||
language: fileMatch.Language,
|
||||
};
|
||||
});
|
||||
},
|
||||
enabled: fileMatch !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<CodePreviewPanel
|
||||
file={file}
|
||||
onClose={onClose}
|
||||
selectedMatchIndex={selectedMatchIndex}
|
||||
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
|
||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
||||
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
|
||||
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
||||
import { SearchResultFile, SearchResultRange } from "@/lib/schemas";
|
||||
import { getRepoCodeHostInfo } from "@/lib/utils";
|
||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
|
||||
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
||||
import CodeMirror, { Decoration, DecorationSet, EditorState, EditorView, ReactCodeMirrorRef, StateField, Transaction } from '@uiw/react-codemirror';
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
const MAX_MATCHES_TO_PREVIEW = 3;
|
||||
|
||||
interface SearchResultsPanelProps {
|
||||
fileMatches: SearchResultFile[];
|
||||
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
|
||||
onMatchIndexChanged: (matchIndex: number) => void;
|
||||
}
|
||||
|
||||
export const SearchResultsPanel = ({
|
||||
fileMatches,
|
||||
onOpenFileMatch,
|
||||
onMatchIndexChanged,
|
||||
}: SearchResultsPanelProps) => {
|
||||
|
||||
if (fileMatches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
{fileMatches.map((fileMatch, index) => (
|
||||
<FilePreview
|
||||
key={index}
|
||||
file={fileMatch}
|
||||
onOpenFile={() => {
|
||||
onOpenFileMatch(fileMatch);
|
||||
}}
|
||||
onMatchIndexChanged={(matchIndex) => {
|
||||
onMatchIndexChanged(matchIndex);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Scrollbar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: SearchResultFile;
|
||||
onOpenFile: () => void;
|
||||
onMatchIndexChanged: (matchIndex: number) => void;
|
||||
}
|
||||
|
||||
const FilePreview = ({
|
||||
file,
|
||||
onOpenFile,
|
||||
onMatchIndexChanged,
|
||||
}: FilePreviewProps) => {
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const matchCount = useMemo(() => {
|
||||
return file.ChunkMatches.length;
|
||||
}, [file]);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const sortedMatches = file.ChunkMatches.sort((a, b) => {
|
||||
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
|
||||
});
|
||||
|
||||
if (!showAll) {
|
||||
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
|
||||
}
|
||||
|
||||
return sortedMatches;
|
||||
}, [file, showAll]);
|
||||
|
||||
const fileNameRange = useMemo(() => {
|
||||
for (const match of matches) {
|
||||
if (match.FileName && match.Ranges.length > 0) {
|
||||
const range = match.Ranges[0];
|
||||
return {
|
||||
from: range.Start.Column - 1,
|
||||
to: range.End.Column - 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [matches]);
|
||||
|
||||
const { repoIcon, repoName, repoLink } = useMemo(() => {
|
||||
const info = getRepoCodeHostInfo(file.Repository);
|
||||
if (info) {
|
||||
return {
|
||||
repoName: info.repoName,
|
||||
repoLink: info.repoLink,
|
||||
repoIcon: <Image
|
||||
src={info.icon}
|
||||
alt={info.costHostName}
|
||||
className="w-4 h-4 dark:invert"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repoName: file.Repository,
|
||||
repoLink: undefined,
|
||||
repoIcon: <FileIcon className="w-4 h-4" />
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const isMoreContentButtonVisible = useMemo(() => {
|
||||
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
||||
}, [matchCount]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="sticky top-0 bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between border cursor-pointer z-10"
|
||||
onClick={() => {
|
||||
onOpenFile();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{repoIcon}
|
||||
<span
|
||||
className={clsx("font-medium", {
|
||||
"cursor-pointer hover:underline": repoLink,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (repoLink) {
|
||||
window.open(repoLink, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{repoName}
|
||||
</span>
|
||||
<span>·</span>
|
||||
{!fileNameRange ? (
|
||||
<span>{file.FileName}</span>
|
||||
) : (
|
||||
<span>
|
||||
{file.FileName.slice(0, fileNameRange.from)}
|
||||
<span className="bg-yellow-200 dark:bg-blue-700">
|
||||
{file.FileName.slice(fileNameRange.from, fileNameRange.to)}
|
||||
</span>
|
||||
{file.FileName.slice(fileNameRange.to)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{matches.map((match, index) => {
|
||||
const content = atob(match.Content);
|
||||
|
||||
// If it's just the title, don't show a code preview
|
||||
if (match.FileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineOffset = match.ContentStart.LineNumber - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
||||
return acc + match.Ranges.length;
|
||||
}, 0);
|
||||
onOpenFile();
|
||||
onMatchIndexChanged(matchIndex);
|
||||
}}
|
||||
>
|
||||
<CodePreview
|
||||
content={content}
|
||||
language={file.Language}
|
||||
ranges={match.Ranges}
|
||||
lineOffset={lineOffset}
|
||||
/>
|
||||
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
|
||||
<Separator className="dark:bg-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isMoreContentButtonVisible && (
|
||||
<div className="px-4 bg-accent">
|
||||
<p
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
|
||||
>
|
||||
{showAll ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
|
||||
{showAll ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markDecoration = Decoration.mark({
|
||||
class: "cm-searchMatch"
|
||||
});
|
||||
|
||||
const cmTheme = EditorView.baseTheme({
|
||||
"&light .cm-searchMatch": {
|
||||
border: "1px #6b7280ff",
|
||||
},
|
||||
"&dark .cm-searchMatch": {
|
||||
border: "1px #d1d5dbff",
|
||||
},
|
||||
});
|
||||
|
||||
const CodePreview = ({
|
||||
content,
|
||||
language,
|
||||
ranges,
|
||||
lineOffset,
|
||||
}: {
|
||||
content: string,
|
||||
language: string,
|
||||
ranges: SearchResultRange[],
|
||||
lineOffset: number,
|
||||
}) => {
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const { theme } = useThemeNormalized();
|
||||
|
||||
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
|
||||
|
||||
const rangeHighlighting = useExtensionWithDependency(editorRef.current?.view ?? null, () => {
|
||||
return [
|
||||
StateField.define<DecorationSet>({
|
||||
create(editorState: EditorState) {
|
||||
const document = editorState.doc;
|
||||
|
||||
const decorations = ranges
|
||||
.sort((a, b) => {
|
||||
return a.Start.ByteOffset - b.Start.ByteOffset;
|
||||
})
|
||||
.filter(({ Start, End }) => {
|
||||
const startLine = Start.LineNumber - lineOffset;
|
||||
const endLine = End.LineNumber - lineOffset;
|
||||
|
||||
if (
|
||||
startLine < 1 ||
|
||||
endLine < 1 ||
|
||||
startLine > document.lines ||
|
||||
endLine > document.lines
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(({ Start, End }) => {
|
||||
const startLine = Start.LineNumber - lineOffset;
|
||||
const endLine = End.LineNumber - lineOffset;
|
||||
|
||||
const from = document.line(startLine).from + Start.Column - 1;
|
||||
const to = document.line(endLine).from + End.Column - 1;
|
||||
return markDecoration.range(from, to);
|
||||
});
|
||||
|
||||
return Decoration.set(decorations);
|
||||
},
|
||||
update(highlights: DecorationSet, _transaction: Transaction) {
|
||||
return highlights;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
}),
|
||||
cmTheme
|
||||
];
|
||||
}, [ranges, lineOffset]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
return [
|
||||
syntaxHighlighting,
|
||||
lineOffsetExtension(lineOffset),
|
||||
rangeHighlighting,
|
||||
];
|
||||
}, [syntaxHighlighting, lineOffset, rangeHighlighting]);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
readOnly={true}
|
||||
editable={false}
|
||||
value={content}
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
syntaxHighlighting: true,
|
||||
|
||||
// Disable all this other stuff...
|
||||
... {
|
||||
foldGutter: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSpecialChars: false,
|
||||
history: false,
|
||||
drawSelection: false,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: false,
|
||||
closeBracketsKeymap: false,
|
||||
defaultKeymap: false,
|
||||
searchKeymap: false,
|
||||
historyKeymap: false,
|
||||
foldKeymap: false,
|
||||
completionKeymap: false,
|
||||
lintKeymap: false,
|
||||
}
|
||||
}}
|
||||
extensions={extensions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||
import { SearchResultRange } from "../schemas";
|
||||
import { SearchResultRange } from "../types";
|
||||
|
||||
const setMatchState = StateEffect.define<{
|
||||
selectedMatchIndex: number,
|
||||
|
|
@ -46,26 +46,10 @@ const matchHighlighter = StateField.define<DecorationSet>({
|
|||
});
|
||||
|
||||
const matchMark = Decoration.mark({
|
||||
class: "tq-searchMatch"
|
||||
class: "cm-searchMatch"
|
||||
});
|
||||
const selectedMatchMark = Decoration.mark({
|
||||
class: "tq-searchMatch-selected"
|
||||
});
|
||||
|
||||
const highlightTheme = EditorView.baseTheme({
|
||||
"&light .tq-searchMatch": {
|
||||
border: "1px dotted #6b7280ff",
|
||||
},
|
||||
"&light .tq-searchMatch-selected": {
|
||||
backgroundColor: "#00ff00aa"
|
||||
},
|
||||
|
||||
"&dark .tq-searchMatch": {
|
||||
border: "1px dotted #d1d5dbff",
|
||||
},
|
||||
"&dark .tq-searchMatch-selected": {
|
||||
backgroundColor: "#00ff007a",
|
||||
}
|
||||
class: "cm-searchMatch-selected"
|
||||
});
|
||||
|
||||
export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResultRange[], view: EditorView) => {
|
||||
|
|
@ -90,7 +74,6 @@ export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResult
|
|||
|
||||
export const searchResultHighlightExtension = (): Extension => {
|
||||
return [
|
||||
highlightTheme,
|
||||
matchHighlighter,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export type SearchRequest = z.infer<typeof searchRequestSchema>;
|
||||
export const searchRequestSchema = z.object({
|
||||
query: z.string(),
|
||||
numResults: z.number(),
|
||||
|
|
@ -8,15 +7,8 @@ export const searchRequestSchema = z.object({
|
|||
});
|
||||
|
||||
|
||||
export type SearchResponse = z.infer<typeof searchResponseSchema>;
|
||||
export type SearchResult = SearchResponse["Result"];
|
||||
export type SearchResultFile = NonNullable<SearchResult["Files"]>[number];
|
||||
export type SearchResultFileMatch = SearchResultFile["ChunkMatches"][number];
|
||||
export type SearchResultRange = z.infer<typeof rangeSchema>;
|
||||
export type SearchResultLocation = z.infer<typeof locationSchema>;
|
||||
|
||||
// @see : https://github.com/TaqlaAI/zoekt/blob/main/api.go#L212
|
||||
const locationSchema = z.object({
|
||||
export const locationSchema = z.object({
|
||||
// 0-based byte offset from the beginning of the file
|
||||
ByteOffset: z.number(),
|
||||
// 1-based line number from the beginning of the file
|
||||
|
|
@ -25,7 +17,7 @@ const locationSchema = z.object({
|
|||
Column: z.number(),
|
||||
});
|
||||
|
||||
const rangeSchema = z.object({
|
||||
export const rangeSchema = z.object({
|
||||
Start: locationSchema,
|
||||
End: locationSchema,
|
||||
});
|
||||
|
|
@ -79,22 +71,16 @@ export const searchResponseSchema = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
||||
export const fileSourceRequestSchema = z.object({
|
||||
fileName: z.string(),
|
||||
repository: z.string()
|
||||
});
|
||||
|
||||
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
||||
|
||||
export const fileSourceResponseSchema = z.object({
|
||||
source: z.string(),
|
||||
});
|
||||
|
||||
|
||||
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
||||
export type Repository = z.infer<typeof repositorySchema>;
|
||||
|
||||
// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
|
||||
const repoStatsSchema = z.object({
|
||||
Repos: z.number(),
|
||||
|
|
@ -120,7 +106,7 @@ const indexMetadataSchema = z.object({
|
|||
});
|
||||
|
||||
// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L555
|
||||
const repositorySchema = z.object({
|
||||
export const repositorySchema = z.object({
|
||||
Name: z.string(),
|
||||
URL: z.string(),
|
||||
Source: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment";
|
||||
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, listRepositoriesResponseSchema, SearchRequest, SearchResponse, searchResponseSchema } from "../schemas";
|
||||
import { listRepositoriesResponseSchema, searchResponseSchema } from "../schemas";
|
||||
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "../types";
|
||||
import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError";
|
||||
import { isServiceError } from "../utils";
|
||||
import { zoektFetch } from "./zoektClient";
|
||||
|
|
|
|||
|
|
@ -1 +1,19 @@
|
|||
export type KeymapType = "default" | "vim";
|
||||
import { z } from "zod";
|
||||
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, searchRequestSchema, searchResponseSchema } from "./schemas";
|
||||
|
||||
export type KeymapType = "default" | "vim";
|
||||
|
||||
export type SearchResponse = z.infer<typeof searchResponseSchema>;
|
||||
export type SearchResult = SearchResponse["Result"];
|
||||
export type SearchResultFile = NonNullable<SearchResult["Files"]>[number];
|
||||
export type SearchResultFileMatch = SearchResultFile["ChunkMatches"][number];
|
||||
export type SearchResultRange = z.infer<typeof rangeSchema>;
|
||||
export type SearchResultLocation = z.infer<typeof locationSchema>;
|
||||
|
||||
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
||||
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
||||
|
||||
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
||||
export type Repository = z.infer<typeof repositorySchema>;
|
||||
export type SearchRequest = z.infer<typeof searchRequestSchema>;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue