Add expanded context results + switch over to using zoekt's json apis

This commit is contained in:
bkellam 2024-09-09 23:16:41 -07:00
parent e14a322c7f
commit 17bf94fc5f
22 changed files with 713 additions and 348 deletions

View file

@ -0,0 +1,30 @@
'use client';
import { FileSourceResponse, fileSourceResponseSchema, SearchRequest, SearchResponse, searchResponseSchema } from "@/lib/schemas";
export const search = async (body: SearchRequest): Promise<SearchResponse> => {
const result = await fetch(`/api/search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}).then(response => response.json());
return searchResponseSchema.parse(result);
}
export const fetchFileSource = async (fileName: string, repository: string): Promise<FileSourceResponse> => {
const result = await fetch(`/api/source`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fileName,
repository,
}),
}).then(response => response.json());
return fileSourceResponseSchema.parse(result);
}

View file

@ -0,0 +1,24 @@
'use server';
import { search } from "@/lib/server/searchService";
import { searchRequestSchema } from "@/lib/schemas";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
export const POST = async (request: NextRequest) => {
const body = await request.json();
const parsed = await searchRequestSchema.safeParseAsync(body);
if (!parsed.success) {
return serviceErrorResponse(
schemaValidationError(parsed.error)
);
}
const response = await search(parsed.data);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return Response.json(response);
}

View file

@ -0,0 +1,24 @@
'use server';
import { fileSourceRequestSchema } from "@/lib/schemas";
import { getFileSource } from "@/lib/server/searchService";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
export const POST = async (request: NextRequest) => {
const body = await request.json();
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
if (!parsed.success) {
return serviceErrorResponse(
schemaValidationError(parsed.error)
);
}
const response = await getFileSource(parsed.data);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}
return Response.json(response);
}

View file

@ -1,21 +0,0 @@
import { ZOEKT_WEBSERVER_URL } from '@/lib/environment';
import { createPathWithQueryParams } from '@/lib/utils';
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
// @todo: proper error handling
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('query');
const numResults = searchParams.get('numResults');
const url = createPathWithQueryParams(
`${ZOEKT_WEBSERVER_URL}/search`,
["q", query],
["num", numResults],
["format", "json"],
);
const res = await fetch(url);
const data = await res.json();
return Response.json({ ...data })
}

View file

@ -1,48 +0,0 @@
"use server";
import { missingQueryParam } from "@/lib/serviceError";
import { StatusCodes } from "http-status-codes";
import { NextRequest } from "next/server";
import { GetSourceResponse, pathQueryParamName, repoQueryParamName, ZoektPrintResponse } from "@/lib/types";
import { ZOEKT_WEBSERVER_URL } from "@/lib/environment";
import { createPathWithQueryParams } from "@/lib/utils";
/**
* Returns the content of a source file at the given path.
*
* Usage:
* GET /api/source?path=<filepath>&repo=<repo>
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const filepath = searchParams.get(pathQueryParamName);
const repo = searchParams.get(repoQueryParamName);
if (!filepath) {
return missingQueryParam(pathQueryParamName);
}
if (!repo) {
return missingQueryParam(repoQueryParamName);
}
const url = createPathWithQueryParams(
`${ZOEKT_WEBSERVER_URL}/print`,
["f", filepath],
["r", repo],
["format", "json"],
);
const res = await fetch(url);
const data = await res.json() as ZoektPrintResponse;
return Response.json(
{
content: data.Content,
encoding: data.Encoding,
} satisfies GetSourceResponse,
{
status: StatusCodes.OK
}
);
}

View file

@ -4,9 +4,11 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency"; import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
import { useKeymapType } from "@/hooks/useKeymapType"; import { useKeymapType } from "@/hooks/useKeymapType";
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
import { markMatches, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
import { ZoektMatch } from "@/lib/types"; import { SearchResultFileMatch } from "@/lib/schemas";
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 { search } from "@codemirror/search";
@ -17,42 +19,33 @@ import { vim } from "@replit/codemirror-vim";
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import clsx from "clsx"; import clsx from "clsx";
import { ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export interface CodePreviewFile { export interface CodePreviewFile {
content: string; content: string;
filepath: string; filepath: string;
link?: string; link?: string;
matches: ZoektMatch[]; matches: SearchResultFileMatch[];
language: string;
} }
interface CodePreviewProps { interface CodePreviewPanelProps {
file?: CodePreviewFile; file?: CodePreviewFile;
selectedMatchIndex: number; selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void; onSelectedMatchIndexChange: (index: number) => void;
onClose: () => void; onClose: () => void;
} }
export const CodePreview = ({ export const CodePreviewPanel = ({
file, file,
selectedMatchIndex, selectedMatchIndex,
onSelectedMatchIndexChange, onSelectedMatchIndexChange,
onClose, onClose,
}: CodePreviewProps) => { }: CodePreviewPanelProps) => {
const editorRef = useRef<ReactCodeMirrorRef>(null); const editorRef = useRef<ReactCodeMirrorRef>(null);
const { theme: _theme, systemTheme } = useTheme();
const [ keymapType ] = useKeymapType(); const [ keymapType ] = useKeymapType();
const { theme } = useThemeNormalized();
const theme = useMemo(() => {
if (_theme === "system") {
return systemTheme ?? "light";
}
return _theme ?? "light";
}, [_theme, systemTheme]);
const [gutterWidth, setGutterWidth] = useState(0); const [gutterWidth, setGutterWidth] = useState(0);
const keymapExtension = useExtensionWithDependency( const keymapExtension = useExtensionWithDependency(
@ -68,11 +61,14 @@ export const CodePreview = ({
[keymapType] [keymapType]
); );
const syntaxHighlighting = useSyntaxHighlightingExtension(file?.language ?? '', editorRef.current?.view);
const extensions = useMemo(() => { const extensions = useMemo(() => {
return [ return [
keymapExtension, keymapExtension,
gutterWidthExtension, gutterWidthExtension,
javascript(), javascript(),
syntaxHighlighting,
searchResultHighlightExtension(), searchResultHighlightExtension(),
search({ search({
top: true, top: true,
@ -84,15 +80,25 @@ export const CodePreview = ({
} }
}), }),
]; ];
}, [keymapExtension]); }, [keymapExtension, syntaxHighlighting]);
const ranges = useMemo(() => {
if (!file || !file.matches.length) {
return [];
}
return file.matches.flatMap((match) => {
return match.Ranges;
})
}, [file]);
useEffect(() => { useEffect(() => {
if (!file || !editorRef.current?.view) { if (!file || !editorRef.current?.view) {
return; return;
} }
markMatches(selectedMatchIndex, file.matches, editorRef.current.view); highlightRanges(selectedMatchIndex, ranges, editorRef.current.view);
}, [file, file?.matches, selectedMatchIndex]); }, [ranges, selectedMatchIndex]);
const onUpClicked = useCallback(() => { const onUpClicked = useCallback(() => {
onSelectedMatchIndexChange(selectedMatchIndex - 1); onSelectedMatchIndexChange(selectedMatchIndex - 1);
@ -126,7 +132,7 @@ export const CodePreview = ({
</span> </span>
</div> </div>
<div className="flex flex-row gap-1 items-center"> <div className="flex flex-row gap-1 items-center">
<p className="text-sm">{`${selectedMatchIndex + 1} of ${file?.matches.length}`}</p> <p className="text-sm">{`${selectedMatchIndex + 1} of ${ranges.length}`}</p>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -141,7 +147,7 @@ export const CodePreview = ({
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={onDownClicked} onClick={onDownClicked}
disabled={file ? selectedMatchIndex === file?.matches.length - 1 : true} disabled={file ? selectedMatchIndex === ranges.length - 1 : true}
> >
<ArrowDown className="h-4 w-4" /> <ArrowDown className="h-4 w-4" />
</Button> </Button>

View file

@ -7,8 +7,7 @@ import {
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { GetSourceResponse, pathQueryParamName, repoQueryParamName, ZoektFileMatch, ZoektSearchResponse } from "@/lib/types"; import { getCodeHostFilePreviewLink } from "@/lib/utils";
import { createPathWithQueryParams, getCodeHostFilePreviewLink } from "@/lib/utils";
import { SymbolIcon } from "@radix-ui/react-icons"; import { SymbolIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Image from "next/image"; import Image from "next/image";
@ -17,9 +16,11 @@ 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 { SearchBar } from "../searchBar"; import { SearchBar } from "../searchBar";
import { SettingsDropdown } from "../settingsDropdown"; import { SettingsDropdown } from "../settingsDropdown";
import { CodePreview, CodePreviewFile } from "./codePreview"; import { CodePreviewPanel, CodePreviewFile } from "./codePreviewPanel";
import { SearchResults } from "./searchResults"; import { SearchResultsPanel } from "./searchResultsPanel";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { fetchFileSource, search } from "../api/(client)/client";
import { SearchResultFile } from "@/lib/schemas";
export default function SearchPage() { export default function SearchPage() {
const router = useRouter(); const router = useRouter();
@ -27,30 +28,28 @@ export default function SearchPage() {
const numResults = useNonEmptyQueryParam("numResults") ?? "100"; const numResults = useNonEmptyQueryParam("numResults") ?? "100";
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [selectedFile, setSelectedFile] = useState<ZoektFileMatch | undefined>(undefined); const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined);
const { data: searchResponse, isLoading } = useQuery({ const { data: searchResponse, isLoading } = useQuery({
queryKey: ["search", searchQuery, numResults], queryKey: ["search", searchQuery, numResults],
queryFn: async (): Promise<ZoektSearchResponse> => { queryFn: () => search({
console.log("Fetching search results"); query: searchQuery,
const result = await fetch(`/api/search?query=${searchQuery}&numResults=${numResults}`) numResults: parseInt(numResults),
.then(response => response.json()); }),
console.log("Done");
return result;
},
enabled: searchQuery.length > 0, enabled: searchQuery.length > 0,
}); });
const { fileMatches, searchDurationMs } = useMemo((): { fileMatches: ZoektFileMatch[], searchDurationMs: number } => { const { fileMatches, searchDurationMs } = useMemo((): { fileMatches: SearchResultFile[], searchDurationMs: number } => {
if (!searchResponse) { if (!searchResponse) {
return { return {
fileMatches: [], fileMatches: [],
searchDurationMs: 0, searchDurationMs: 0,
}; };
} }
return { return {
fileMatches: searchResponse.result.FileMatches ?? [], fileMatches: searchResponse.Result.Files ?? [],
searchDurationMs: Math.round(searchResponse.result.Stats.Duration / 1000000), searchDurationMs: Math.round(searchResponse.Result.Duration / 1000000),
} }
}, [searchResponse]); }, [searchResponse]);
@ -100,7 +99,7 @@ export default function SearchPage() {
{/* Search Results & Code Preview */} {/* Search Results & Code Preview */}
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
<ResizablePanel minSize={20}> <ResizablePanel minSize={20}>
<SearchResults <SearchResultsPanel
fileMatches={fileMatches} fileMatches={fileMatches}
onOpenFileMatch={(fileMatch, matchIndex) => { onOpenFileMatch={(fileMatch, matchIndex) => {
setSelectedFile(fileMatch); setSelectedFile(fileMatch);
@ -126,7 +125,7 @@ export default function SearchPage() {
} }
interface CodePreviewWrapperProps { interface CodePreviewWrapperProps {
fileMatch?: ZoektFileMatch; fileMatch?: SearchResultFile;
onClose: () => void; onClose: () => void;
selectedMatchIndex: number; selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void; onSelectedMatchIndexChange: (index: number) => void;
@ -140,33 +139,25 @@ const CodePreviewWrapper = ({
}: CodePreviewWrapperProps) => { }: CodePreviewWrapperProps) => {
const { data: file } = useQuery({ const { data: file } = useQuery({
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repo], queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository],
queryFn: async (): Promise<CodePreviewFile | undefined> => { queryFn: async (): Promise<CodePreviewFile | undefined> => {
if (!fileMatch) { if (!fileMatch) {
return undefined; return undefined;
} }
const url = createPathWithQueryParams( return fetchFileSource(fileMatch.FileName, fileMatch.Repository)
`/api/source`, .then(({ source }) => {
[pathQueryParamName, fileMatch.FileName], // @todo : refector this to use the templates provided by zoekt.
[repoQueryParamName, fileMatch.Repo] const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName)
);
return fetch(url) const decodedSource = atob(source);
.then(response => response.json())
.then((body: GetSourceResponse) => {
if (body.encoding !== "base64") {
throw new Error("Expected base64 encoding");
}
const content = atob(body.content);
const link = getCodeHostFilePreviewLink(fileMatch.Repo, fileMatch.FileName)
return { return {
content, content: decodedSource,
filepath: fileMatch.FileName, filepath: fileMatch.FileName,
matches: fileMatch.Matches, matches: fileMatch.ChunkMatches,
link: link, link: link,
language: fileMatch.Language,
}; };
}); });
}, },
@ -174,7 +165,7 @@ const CodePreviewWrapper = ({
}); });
return ( return (
<CodePreview <CodePreviewPanel
file={file} file={file}
onClose={onClose} onClose={onClose}
selectedMatchIndex={selectedMatchIndex} selectedMatchIndex={selectedMatchIndex}

View file

@ -1,137 +0,0 @@
'use client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { ZoektFileMatch } from "@/lib/types";
import { Scrollbar } from "@radix-ui/react-scroll-area";
import { useMemo, useState } from "react";
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
import Image from "next/image";
import clsx from "clsx";
import { getRepoCodeHostInfo } from "@/lib/utils";
const MAX_MATCHES_TO_PREVIEW = 5;
interface SearchResultsProps {
fileMatches: ZoektFileMatch[];
onOpenFileMatch: (fileMatch: ZoektFileMatch, matchIndex: number) => void;
}
export const SearchResults = ({
fileMatches,
onOpenFileMatch,
}: SearchResultsProps) => {
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-2">
{fileMatches.map((fileMatch, index) => (
<FileMatch
key={index}
match={fileMatch}
onOpenFile={(matchIndex) => {
onOpenFileMatch(fileMatch, matchIndex);
}}
/>
))}
</div>
<Scrollbar orientation="vertical" />
</ScrollArea>
)
}
interface FileMatchProps {
match: ZoektFileMatch;
onOpenFile: (matchIndex: number) => void;
}
const FileMatch = ({
match,
onOpenFile,
}: FileMatchProps) => {
const [showAll, setShowAll] = useState(false);
const matchCount = useMemo(() => {
return match.Matches.length;
}, [match]);
const matches = useMemo(() => {
const sortedMatches = match.Matches.sort((a, b) => {
return a.LineNum - b.LineNum;
});
if (!showAll) {
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
}
return sortedMatches;
}, [match, showAll]);
const { repoIcon, repoName, repoLink } = useMemo(() => {
const info = getRepoCodeHostInfo(match.Repo);
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: match.Repo,
repoLink: undefined,
repoIcon: <FileIcon className="w-4 h-4" />
}
}, [match]);
return (
<div>
<div className="bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 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>· {match.FileName}</span>
</div>
{matches.map((match, index) => {
const fragment = match.Fragments[0];
return (
<div
key={index}
className="font-mono px-4 py-0.5 text-sm cursor-pointer"
onClick={() => {
onOpenFile(index);
}}
>
<p>{match.LineNum > 0 ? match.LineNum : "file match"}: {fragment.Pre}<span className="font-bold">{fragment.Match}</span>{fragment.Post}</p>
<Separator />
</div>
);
})}
{matchCount > MAX_MATCHES_TO_PREVIEW && (
<div className="px-4">
<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 matching lines` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matching lines`}
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,269 @@
'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, matchIndex: number) => void;
}
export const SearchResultsPanel = ({
fileMatches,
onOpenFileMatch,
}: SearchResultsPanelProps) => {
return (
<ScrollArea className="h-full">
{fileMatches.map((fileMatch, index) => (
<FilePreview
key={index}
file={fileMatch}
onOpenFile={(matchIndex) => {
onOpenFileMatch(fileMatch, matchIndex);
}}
/>
))}
<Scrollbar orientation="vertical" />
</ScrollArea>
)
}
interface FilePreviewProps {
file: SearchResultFile;
onOpenFile: (matchIndex: number) => void;
}
const FilePreview = ({
file,
onOpenFile,
}: 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 { 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 className="flex flex-col">
<div className="bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between border">
<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>· {file.FileName}</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(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;
})
.map(({ Start, End }) => {
const from = document.line(Start.LineNumber - lineOffset).from + Start.Column - 1;
const to = document.line(End.LineNumber - lineOffset).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]);
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}
/>
)
}

View file

@ -0,0 +1,26 @@
'use client';
import { EditorView } from "@codemirror/view";
import { useExtensionWithDependency } from "./useExtensionWithDependency";
import { javascript } from "@codemirror/lang-javascript";
export const useSyntaxHighlightingExtension = (language: string, view: EditorView | undefined) => {
const extension = useExtensionWithDependency(
view ?? null,
() => {
switch (language.toLowerCase()) {
case "typescript":
case "javascript":
return javascript({
jsx: true,
typescript: true,
});
default:
return [];
}
},
[language]
);
return extension;
}

View file

@ -0,0 +1,22 @@
'use client';
import { useTheme as useThemeBase } from "next-themes";
import { useMemo } from "react";
export const useThemeNormalized = (defaultTheme: "light" | "dark" = "light") => {
const { theme: _theme, systemTheme, setTheme } = useThemeBase();
const theme = useMemo(() => {
if (_theme === "system") {
return systemTheme ?? defaultTheme;
}
return _theme ?? defaultTheme;
}, [_theme, systemTheme]);
return {
theme,
systemTheme,
setTheme,
};
}

View file

@ -3,6 +3,12 @@ const getEnv = (env: string | undefined, defaultValue = '') => {
return env ?? defaultValue; return env ?? defaultValue;
} }
const getEnvNumber = (env: string | undefined, defaultValue: number = 0) => {
return Number(env) ?? defaultValue;
}
export const ZOEKT_WEBSERVER_URL = getEnv(process.env.ZOEKT_WEBSERVER_URL, "http://localhost:6070"); export const ZOEKT_WEBSERVER_URL = getEnv(process.env.ZOEKT_WEBSERVER_URL, "http://localhost:6070");
export const SHARD_MAX_MATCH_COUNT = getEnvNumber(process.env.SHARD_MAX_MATCH_COUNT, 10000);
export const TOTAL_MAX_MATCH_COUNT = getEnvNumber(process.env.TOTAL_MAX_MATCH_COUNT, 100000);
export const NODE_ENV = process.env.NODE_ENV; export const NODE_ENV = process.env.NODE_ENV;

View file

@ -3,4 +3,5 @@ export enum ErrorCode {
MISSING_REQUIRED_QUERY_PARAMETER = 'MISSING_REQUIRED_QUERY_PARAMETER', MISSING_REQUIRED_QUERY_PARAMETER = 'MISSING_REQUIRED_QUERY_PARAMETER',
REPOSITORY_NOT_FOUND = 'REPOSITORY_NOT_FOUND', REPOSITORY_NOT_FOUND = 'REPOSITORY_NOT_FOUND',
FILE_NOT_FOUND = 'FILE_NOT_FOUND', FILE_NOT_FOUND = 'FILE_NOT_FOUND',
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
} }

View file

@ -0,0 +1,20 @@
import { Compartment } from "@codemirror/state";
import { lineNumbers } from "@codemirror/view";
const gutter = new Compartment();
/**
* Offsets the line numbers by the given amount
* @see: https://discuss.codemirror.net/t/codemirror-6-offset-line-numbers/2675/8
*/
export const lineOffsetExtension = (lineOffset: number) => {
const lines = lineNumbers({
formatNumber: (n) => {
return (n + lineOffset).toString();
}
});
return [
gutter.of(lines)
]
}

View file

@ -1,24 +1,16 @@
import { EditorSelection, Extension, StateEffect, StateField, Text, 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 { SearchResultRange } from "../schemas";
const matchMark = Decoration.mark({
class: "tq-searchMatch"
});
const selectedMatchMark = Decoration.mark({
class: "tq-searchMatch-selected"
});
const setMatchState = StateEffect.define<{ const setMatchState = StateEffect.define<{
selectedMatchIndex: number, selectedMatchIndex: number,
matches: ZoektMatch[], ranges: SearchResultRange[],
}>(); }>();
const getMatchRange = (match: ZoektMatch, document: Text) => { const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => {
const line = document.line(match.LineNum); const { Start, End } = range;
const fragment = match.Fragments[0]; const from = document.line(Start.LineNumber).from + Start.Column - 1;
const from = line.from + fragment.Pre.length; const to = document.line(End.LineNumber).from + End.Column - 1;
const to = from + fragment.Match.length;
return { from, to }; return { from, to };
} }
@ -32,12 +24,14 @@ const matchHighlighter = StateField.define<DecorationSet>({
for (const effect of transaction.effects) { for (const effect of transaction.effects) {
if (effect.is(setMatchState)) { if (effect.is(setMatchState)) {
const { matches, selectedMatchIndex } = effect.value; const { ranges, selectedMatchIndex } = effect.value;
const decorations = matches const decorations = ranges
.filter((match) => match.LineNum > 0) .sort((a, b) => {
.map((match, index) => { return a.Start.ByteOffset - b.Start.ByteOffset;
const { from, to } = getMatchRange(match, transaction.newDoc); })
.map((range, index) => {
const { from, to } = convertToCodeMirrorRange(range, transaction.newDoc);
const mark = index === selectedMatchIndex ? selectedMatchMark : matchMark; const mark = index === selectedMatchIndex ? selectedMatchMark : matchMark;
return mark.range(from, to); return mark.range(from, to);
}); });
@ -51,6 +45,13 @@ const matchHighlighter = StateField.define<DecorationSet>({
provide: (field) => EditorView.decorations.from(field), provide: (field) => EditorView.decorations.from(field),
}); });
const matchMark = Decoration.mark({
class: "tq-searchMatch"
});
const selectedMatchMark = Decoration.mark({
class: "tq-searchMatch-selected"
});
const highlightTheme = EditorView.baseTheme({ const highlightTheme = EditorView.baseTheme({
"&light .tq-searchMatch": { "&light .tq-searchMatch": {
border: "1px dotted #6b7280ff", border: "1px dotted #6b7280ff",
@ -64,34 +65,27 @@ const highlightTheme = EditorView.baseTheme({
}, },
"&dark .tq-searchMatch-selected": { "&dark .tq-searchMatch-selected": {
backgroundColor: "#00ff007a", backgroundColor: "#00ff007a",
} }
}); });
export const markMatches = (selectedMatchIndex: number, matches: ZoektMatch[], view: EditorView) => { export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResultRange[], view: EditorView) => {
const setState = setMatchState.of({ const setState = setMatchState.of({
selectedMatchIndex, selectedMatchIndex,
matches, ranges,
}); });
const effects = [] const effects = []
effects.push(setState); effects.push(setState);
if (selectedMatchIndex >= 0 && selectedMatchIndex < matches.length) { if (selectedMatchIndex >= 0 && selectedMatchIndex < ranges.length) {
const match = matches[selectedMatchIndex]; const { from, to } = convertToCodeMirrorRange(ranges[selectedMatchIndex], view.state.doc);
const selection = EditorSelection.range(from, to);
// Don't scroll if the match is on the filename. effects.push(EditorView.scrollIntoView(selection, {
if (match.LineNum > 0) { y: "start",
const { from, to } = getMatchRange(match, view.state.doc); }));
const selection = EditorSelection.range(from, to);
effects.push(EditorView.scrollIntoView(selection, {
y: "start",
}));
}
}; };
view.dispatch({ effects }); view.dispatch({ effects });
return true;
} }
export const searchResultHighlightExtension = (): Extension => { export const searchResultHighlightExtension = (): Extension => {

68
src/lib/schemas.ts Normal file
View file

@ -0,0 +1,68 @@
import { z } from "zod";
export type SearchRequest = z.infer<typeof searchRequestSchema>;
export const searchRequestSchema = z.object({
query: z.string(),
numResults: z.number(),
whole: z.optional(z.boolean()),
});
export type SearchResponse = z.infer<typeof searchResponseSchema>;
export type SearchResult = SearchResponse["Result"];
export type SearchResultFile = 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({
// 0-based byte offset from the beginning of the file
ByteOffset: z.number(),
// 1-based line number from the beginning of the file
LineNumber: z.number(),
// 1-based column number (in runes) from the beginning of line
Column: z.number(),
});
const rangeSchema = z.object({
Start: locationSchema,
End: locationSchema,
});
export const searchResponseSchema = z.object({
Result: z.object({
Duration: z.number(),
FileCount: z.number(),
Files: z.array(z.object({
FileName: z.string(),
Repository: z.string(),
Version: z.string(),
Language: z.string(),
Branches: z.array(z.string()),
ChunkMatches: z.array(z.object({
Content: z.string(),
Ranges: z.array(rangeSchema),
FileName: z.boolean(),
ContentStart: locationSchema,
Score: z.number(),
})),
Checksum: z.string(),
Score: z.number(),
// Set if `whole` is true.
Content: z.optional(z.string()),
})),
}),
});
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(),
});

View file

@ -0,0 +1,56 @@
import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment";
import { FileSourceRequest, FileSourceResponse, SearchRequest, SearchResponse, searchResponseSchema } from "../schemas";
import { fileNotFound, invalidZoektResponse, ServiceError } from "../serviceError";
import { isServiceError } from "../utils";
import { zoektFetch } from "./zoektClient";
export const search = async ({ query, numResults, whole }: SearchRequest): Promise<SearchResponse | ServiceError> => {
const body = JSON.stringify({
q: query,
// @see: https://github.com/TaqlaAI/zoekt/blob/main/api.go#L892
opts: {
NumContextLines: 2,
ChunkMatches: true,
MaxMatchDisplayCount: numResults,
Whole: !!whole,
ShardMaxMatchCount: SHARD_MAX_MATCH_COUNT,
TotalMaxMatchCount: TOTAL_MAX_MATCH_COUNT,
}
});
const searchResponse = await zoektFetch({
path: "/api/search",
body,
method: "POST",
});
if (!searchResponse.ok) {
return invalidZoektResponse(searchResponse);
}
const searchBody = await searchResponse.json();
return searchResponseSchema.parse(searchBody);
}
export const getFileSource = async ({ fileName, repository }: FileSourceRequest): Promise<FileSourceResponse | ServiceError> => {
const searchResponse = await search({
query: `${fileName} repo:${repository}`,
numResults: 1,
whole: true,
});
if (isServiceError(searchResponse)) {
return searchResponse;
}
const files = searchResponse.Result.Files;
if (files.length === 0) {
return fileNotFound(fileName, repository);
}
const source = files[0].Content ?? '';
return {
source
}
}

View file

@ -0,0 +1,33 @@
import { ZOEKT_WEBSERVER_URL } from "../environment"
interface ZoektRequest {
path: string,
body: string,
method: string,
}
export const zoektFetch = async ({
path,
body,
method,
}: ZoektRequest) => {
const start = Date.now();
const response = await fetch(
new URL(path, ZOEKT_WEBSERVER_URL),
{
method,
headers: {
"Content-Type": "application/json",
},
body,
}
);
const duration = Date.now() - start;
console.log(`[zoektClient] ${method} ${path} ${response.status} ${duration}ms`);
// @todo : add metrics
return response;
}

View file

@ -1,13 +1,14 @@
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./errorCodes"; import { ErrorCode } from "./errorCodes";
import { ZodError } from "zod";
export interface ServiceErrorArgs { export interface ServiceError {
statusCode: StatusCodes; statusCode: StatusCodes;
errorCode: ErrorCode; errorCode: ErrorCode;
message: string; message: string;
} }
export const serviceError = ({ statusCode, errorCode, message }: ServiceErrorArgs) => { export const serviceErrorResponse = ({ statusCode, errorCode, message }: ServiceError) => {
return Response.json({ return Response.json({
statusCode, statusCode,
errorCode, errorCode,
@ -17,10 +18,45 @@ export const serviceError = ({ statusCode, errorCode, message }: ServiceErrorArg
}); });
} }
export const missingQueryParam = (name: string) => { export const missingQueryParam = (name: string): ServiceError => {
return serviceError({ return {
statusCode: StatusCodes.BAD_REQUEST, statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.MISSING_REQUIRED_QUERY_PARAMETER, errorCode: ErrorCode.MISSING_REQUIRED_QUERY_PARAMETER,
message: `Missing required query parameter: ${name}`, message: `Missing required query parameter: ${name}`,
}); };
}
export const schemaValidationError = (error: ZodError): ServiceError => {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Schema validation failed with: ${error.message}`,
};
}
export const invalidZoektResponse = async (zoektResponse: Response): Promise<ServiceError> => {
const zoektMessage = await (async () => {
try {
const zoektResponseBody = await zoektResponse.json();
if (zoektResponseBody.Error) {
return zoektResponseBody.Error;
}
} catch (_e) {
return "Unknown error";
}
})();
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Zoekt request failed with status code ${zoektResponse.status} and message: "${zoektMessage}"`,
};
}
export const fileNotFound = async (fileName: string, repository: string): Promise<ServiceError> => {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.FILE_NOT_FOUND,
message: `File "${fileName}" not found in repository "${repository}"`,
};
} }

View file

@ -1,48 +1,4 @@
export const pathQueryParamName = "path"; export const pathQueryParamName = "path";
export const repoQueryParamName = "repo"; export const repoQueryParamName = "repo";
export type GetSourceResponse = {
content: string;
encoding: string;
}
export interface ZoektMatch {
URL: string,
FileName: string,
LineNum: number,
Fragments: {
Pre: string,
Match: string,
Post: string
}[]
}
export interface ZoektFileMatch {
FileName: string,
Repo: string,
Language: string,
Matches: ZoektMatch[],
URL: string,
}
export interface ZoektResult {
QueryStr: string,
FileMatches: ZoektFileMatch[] | null,
Stats: {
// Duration in nanoseconds
Duration: number,
}
}
export interface ZoektSearchResponse {
result: ZoektResult,
}
export interface ZoektPrintResponse {
Content: string,
Encoding: string,
}
export type KeymapType = "default" | "vim"; export type KeymapType = "default" | "vim";

View file

@ -2,6 +2,7 @@ import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import githubLogo from "../../public/github.svg"; import githubLogo from "../../public/github.svg";
import gitlabLogo from "../../public/gitlab.svg"; import gitlabLogo from "../../public/gitlab.svg";
import { ServiceError } from "./serviceError";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -72,3 +73,11 @@ export const getCodeHostFilePreviewLink = (repoName: string, filePath: string):
return undefined; return undefined;
} }
export const isServiceError = (data: unknown): data is ServiceError => {
return typeof data === 'object' &&
data !== null &&
'statusCode' in data &&
'errorCode' in data &&
'message' in data;
}

View file

@ -13,7 +13,7 @@ stdout_logfile_maxbytes=0
redirect_stderr=true redirect_stderr=true
[program:zoekt-webserver] [program:zoekt-webserver]
command=zoekt-webserver -index %(ENV_DATA_CACHE_DIR)s/index command=zoekt-webserver -index %(ENV_DATA_CACHE_DIR)s/index -rpc
autostart=true autostart=true
autorestart=true autorestart=true
startretries=3 startretries=3