mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
chore: Sourcebot REST api surface (#290)
This commit is contained in:
parent
0119510ce3
commit
eb10d599f3
36 changed files with 746 additions and 550 deletions
|
|
@ -7,7 +7,6 @@
|
|||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"next/core-web-vitals"
|
||||
],
|
||||
"rules": {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "cross-env SKIP_ENV_VALIDATION=1 next lint",
|
||||
"test": "vitest",
|
||||
"dev:emails": "email dev --dir ./src/emails",
|
||||
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
|
||||
|
|
@ -146,6 +146,7 @@
|
|||
"@types/react-dom": "^18",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.6",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
||||
import { TopBar } from "@/app/[domain]/components/topBar";
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { getFileSource, listRepositories } from '@/lib/server/searchService';
|
||||
import { getFileSource } from '@/features/search/fileSourceApi';
|
||||
import { listRepositories } from '@/features/search/listReposApi';
|
||||
import { base64Decode, isServiceError } from "@/lib/utils";
|
||||
import { CodePreview } from "./codePreview";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
|
|
@ -57,7 +58,7 @@ export default async function BrowsePage({
|
|||
if (isServiceError(reposResponse)) {
|
||||
throw new ServiceErrorException(reposResponse);
|
||||
}
|
||||
const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName);
|
||||
const repo = reposResponse.repos.find(r => r.name === repoName);
|
||||
|
||||
if (pathType === 'tree') {
|
||||
// @todo : proper tree handling
|
||||
|
|
@ -81,7 +82,7 @@ export default async function BrowsePage({
|
|||
<div className="bg-accent py-1 px-2 flex flex-row">
|
||||
<FileHeader
|
||||
fileName={path}
|
||||
repo={repo.Repository}
|
||||
repo={repo}
|
||||
branchDisplayName={revisionName}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Repository } from "@/lib/types";
|
||||
import { Repository } from "@/features/search/types";
|
||||
import { getRepoCodeHostInfo } from "@/lib/utils";
|
||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||
import clsx from "clsx";
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
searchHistorySuggestions,
|
||||
languageSuggestions,
|
||||
searchContextSuggestions,
|
||||
refineModeSuggestions,
|
||||
]);
|
||||
|
||||
// When the list of suggestions change, reset the highlight index
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export const useSuggestionModeAndQuery = ({
|
|||
suggestionQuery: part,
|
||||
suggestionMode: "refine",
|
||||
}
|
||||
}, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled]);
|
||||
}, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled, suggestionModeMappings]);
|
||||
|
||||
// Debug logging for tracking mode transitions.
|
||||
const [prevSuggestionMode, setPrevSuggestionMode] = useState<SuggestionMode>("none");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Suggestion, SuggestionMode } from "./searchSuggestionsBox";
|
|||
import { getRepos, search } from "@/app/api/(client)/client";
|
||||
import { getSearchContexts } from "@/actions";
|
||||
import { useMemo } from "react";
|
||||
import { Symbol } from "@/lib/types";
|
||||
import { SearchSymbol } from "@/features/search/types";
|
||||
import { languageMetadataMap } from "@/lib/languageMetadata";
|
||||
import {
|
||||
VscSymbolClass,
|
||||
|
|
@ -40,10 +40,9 @@ export const useSuggestionsData = ({
|
|||
queryKey: ["repoSuggestions"],
|
||||
queryFn: () => getRepos(domain),
|
||||
select: (data): Suggestion[] => {
|
||||
return data.List.Repos
|
||||
.map(r => r.Repository)
|
||||
return data.repos
|
||||
.map(r => ({
|
||||
value: r.Name
|
||||
value: r.name,
|
||||
}));
|
||||
},
|
||||
enabled: suggestionMode === "repo",
|
||||
|
|
@ -54,16 +53,17 @@ export const useSuggestionsData = ({
|
|||
queryKey: ["fileSuggestions", suggestionQuery],
|
||||
queryFn: () => search({
|
||||
query: `file:${suggestionQuery}`,
|
||||
maxMatchDisplayCount: 15,
|
||||
matches: 15,
|
||||
contextLines: 1,
|
||||
}, domain),
|
||||
select: (data): Suggestion[] => {
|
||||
if (isServiceError(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.Result.Files?.map((file) => ({
|
||||
value: file.FileName
|
||||
})) ?? [];
|
||||
return data.files.map((file) => ({
|
||||
value: file.fileName.text,
|
||||
}));
|
||||
},
|
||||
enabled: suggestionMode === "file"
|
||||
});
|
||||
|
|
@ -73,22 +73,23 @@ export const useSuggestionsData = ({
|
|||
queryKey: ["symbolSuggestions", suggestionQuery],
|
||||
queryFn: () => search({
|
||||
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
||||
maxMatchDisplayCount: 15,
|
||||
matches: 15,
|
||||
contextLines: 1,
|
||||
}, domain),
|
||||
select: (data): Suggestion[] => {
|
||||
if (isServiceError(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
|
||||
const symbols = data.files.flatMap((file) => file.chunks).flatMap((chunk) => chunk.symbols ?? []);
|
||||
if (!symbols) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// De-duplicate on symbol name & kind.
|
||||
const symbolMap = new Map<string, Symbol>(symbols.map((symbol: Symbol) => [`${symbol.Kind}.${symbol.Sym}`, symbol]));
|
||||
const symbolMap = new Map<string, SearchSymbol>(symbols.map((symbol: SearchSymbol) => [`${symbol.kind}.${symbol.symbol}`, symbol]));
|
||||
const suggestions = Array.from(symbolMap.values()).map((symbol) => ({
|
||||
value: symbol.Sym,
|
||||
value: symbol.symbol,
|
||||
Icon: getSymbolIcon(symbol),
|
||||
} satisfies Suggestion));
|
||||
|
||||
|
|
@ -157,8 +158,8 @@ export const useSuggestionsData = ({
|
|||
}
|
||||
}
|
||||
|
||||
const getSymbolIcon = (symbol: Symbol) => {
|
||||
switch (symbol.Kind) {
|
||||
const getSymbolIcon = (symbol: SearchSymbol) => {
|
||||
switch (symbol.kind) {
|
||||
case "methodSpec":
|
||||
case "method":
|
||||
case "function":
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { isServiceError } from "@/lib/utils"
|
|||
import { notFound } from "next/navigation"
|
||||
import { OrgRole } from "@sourcebot/db"
|
||||
import { CodeHostType } from "@/lib/utils"
|
||||
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"
|
||||
|
||||
interface ConnectionManagementPageProps {
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[
|
|||
selectionText: "username",
|
||||
description: (
|
||||
<div className="flex flex-col">
|
||||
<span>Username to use for authentication. This is only required if you're using an App Password (stored in <Code>token</Code>) for authentication.</span>
|
||||
<span>Username to use for authentication. This is only required if you're using an App Password (stored in <Code>token</Code>) for authentication.</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { SearchResultChunk } from "@/features/search/types";
|
||||
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
||||
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
||||
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
||||
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
||||
import { SearchResultFileMatch } from "@/lib/types";
|
||||
import { search } from "@codemirror/search";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
||||
|
|
@ -22,7 +22,7 @@ export interface CodePreviewFile {
|
|||
content: string;
|
||||
filepath: string;
|
||||
link?: string;
|
||||
matches: SearchResultFileMatch[];
|
||||
matches: SearchResultChunk[];
|
||||
language: string;
|
||||
revision: string;
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ export const CodePreview = ({
|
|||
}
|
||||
|
||||
return file.matches.flatMap((match) => {
|
||||
return match.Ranges;
|
||||
return match.matchRanges;
|
||||
})
|
||||
}, [file]);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { fetchFileSource } from "@/app/api/(client)/client";
|
|||
import { base64Decode } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CodePreview, CodePreviewFile } from "./codePreview";
|
||||
import { SearchResultFile } from "@/lib/types";
|
||||
import { SearchResultFile } from "@/features/search/types";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||
|
||||
interface CodePreviewPanelProps {
|
||||
fileMatch?: SearchResultFile;
|
||||
onClose: () => void;
|
||||
|
|
@ -25,7 +26,7 @@ export const CodePreviewPanel = ({
|
|||
const domain = useDomain();
|
||||
|
||||
const { data: file, isLoading } = useQuery({
|
||||
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository, fileMatch?.Branches],
|
||||
queryKey: ["source", fileMatch?.fileName, fileMatch?.repository, fileMatch?.branches],
|
||||
queryFn: async (): Promise<CodePreviewFile | undefined> => {
|
||||
if (!fileMatch) {
|
||||
return undefined;
|
||||
|
|
@ -33,16 +34,16 @@ export const CodePreviewPanel = ({
|
|||
|
||||
// If there are multiple branches pointing to the same revision of this file, it doesn't
|
||||
// matter which branch we use here, so use the first one.
|
||||
const branch = fileMatch.Branches && fileMatch.Branches.length > 0 ? fileMatch.Branches[0] : undefined;
|
||||
const branch = fileMatch.branches && fileMatch.branches.length > 0 ? fileMatch.branches[0] : undefined;
|
||||
|
||||
return fetchFileSource({
|
||||
fileName: fileMatch.FileName,
|
||||
repository: fileMatch.Repository,
|
||||
fileName: fileMatch.fileName.text,
|
||||
repository: fileMatch.repository,
|
||||
branch,
|
||||
}, domain)
|
||||
.then(({ source }) => {
|
||||
const link = (() => {
|
||||
const template = repoUrlTemplates[fileMatch.Repository];
|
||||
const template = repoUrlTemplates[fileMatch.repository];
|
||||
|
||||
// This is a hacky parser for templates generated by
|
||||
// the go text/template package. Example template:
|
||||
|
|
@ -55,7 +56,7 @@ export const CodePreviewPanel = ({
|
|||
const url =
|
||||
template.substring("{{URLJoinPath ".length,template.indexOf("}}"))
|
||||
.replace(".Version", branch ?? "HEAD")
|
||||
.replace(".Path", fileMatch.FileName)
|
||||
.replace(".Path", fileMatch.fileName.text)
|
||||
.split(" ")
|
||||
.map((part) => {
|
||||
// remove wrapping quotes
|
||||
|
|
@ -68,24 +69,19 @@ export const CodePreviewPanel = ({
|
|||
const optionalQueryParams =
|
||||
template.substring(template.indexOf("}}") + 2)
|
||||
.replace("{{.Version}}", branch ?? "HEAD")
|
||||
.replace("{{.Path}}", fileMatch.FileName);
|
||||
.replace("{{.Path}}", fileMatch.fileName.text);
|
||||
|
||||
return url + optionalQueryParams;
|
||||
})();
|
||||
|
||||
const decodedSource = base64Decode(source);
|
||||
|
||||
// Filter out filename matches
|
||||
const filteredMatches = fileMatch.ChunkMatches.filter((match) => {
|
||||
return !match.FileName;
|
||||
});
|
||||
|
||||
return {
|
||||
content: decodedSource,
|
||||
filepath: fileMatch.FileName,
|
||||
matches: filteredMatches,
|
||||
filepath: fileMatch.fileName.text,
|
||||
matches: fileMatch.chunks,
|
||||
link: link,
|
||||
language: fileMatch.Language,
|
||||
language: fileMatch.language,
|
||||
revision: branch ?? "HEAD",
|
||||
};
|
||||
});
|
||||
|
|
@ -103,7 +99,7 @@ export const CodePreviewPanel = ({
|
|||
return (
|
||||
<CodePreview
|
||||
file={file}
|
||||
repoName={fileMatch?.Repository}
|
||||
repoName={fileMatch?.repository}
|
||||
onClose={onClose}
|
||||
selectedMatchIndex={selectedMatchIndex}
|
||||
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { compareEntries, Entry } from "./entry";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import Fuse from "fuse.js";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { FileIcon } from "@/components/ui/fileIcon";
|
||||
import { Repository, SearchResultFile } from "@/lib/types";
|
||||
import { Repository, SearchResultFile } from "@/features/search/types";
|
||||
import { cn, getRepoCodeHostInfo } from "@/lib/utils";
|
||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Entry } from "./entry";
|
||||
import { Filter } from "./filter";
|
||||
|
||||
|
|
@ -28,15 +28,15 @@ export const FilterPanel = ({
|
|||
const searchParams = useSearchParams();
|
||||
|
||||
// Helper to parse query params into sets
|
||||
const getSelectedFromQuery = (param: string) => {
|
||||
const getSelectedFromQuery = useCallback((param: string) => {
|
||||
const value = searchParams.get(param);
|
||||
return value ? new Set(value.split(',')) : new Set();
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const repos = useMemo(() => {
|
||||
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
|
||||
return aggregateMatches(
|
||||
"Repository",
|
||||
"repository",
|
||||
matches,
|
||||
(key) => {
|
||||
const repo: Repository | undefined = repoMetadata[key];
|
||||
|
|
@ -60,12 +60,12 @@ export const FilterPanel = ({
|
|||
};
|
||||
}
|
||||
)
|
||||
}, [searchParams]);
|
||||
}, [getSelectedFromQuery, matches, repoMetadata]);
|
||||
|
||||
const languages = useMemo(() => {
|
||||
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
||||
return aggregateMatches(
|
||||
"Language",
|
||||
"language",
|
||||
matches,
|
||||
(key) => {
|
||||
const Icon = (
|
||||
|
|
@ -81,7 +81,7 @@ export const FilterPanel = ({
|
|||
} satisfies Entry;
|
||||
}
|
||||
);
|
||||
}, [searchParams]);
|
||||
}, [getSelectedFromQuery, matches]);
|
||||
|
||||
// Calls `onFilterChanged` with the filtered list of matches
|
||||
// whenever the filter state changes.
|
||||
|
|
@ -91,8 +91,8 @@ export const FilterPanel = ({
|
|||
|
||||
const filteredMatches = matches.filter((match) =>
|
||||
(
|
||||
(selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) &&
|
||||
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language))
|
||||
(selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) &&
|
||||
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language))
|
||||
)
|
||||
);
|
||||
onFilterChanged(filteredMatches);
|
||||
|
|
@ -166,7 +166,7 @@ export const FilterPanel = ({
|
|||
* }
|
||||
*/
|
||||
const aggregateMatches = (
|
||||
propName: 'Repository' | 'Language',
|
||||
propName: 'repository' | 'language',
|
||||
matches: SearchResultFile[],
|
||||
createEntry: (key: string) => Entry
|
||||
) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage";
|
||||
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
||||
import { SearchResultRange } from "@/lib/types";
|
||||
import { SearchResultRange } from "@/features/search/types";
|
||||
import { EditorState, StateField, Transaction } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
|
@ -43,11 +43,11 @@ export const CodePreview = ({
|
|||
|
||||
const decorations = ranges
|
||||
.sort((a, b) => {
|
||||
return a.Start.ByteOffset - b.Start.ByteOffset;
|
||||
return a.start.byteOffset - b.start.byteOffset;
|
||||
})
|
||||
.filter(({ Start, End }) => {
|
||||
const startLine = Start.LineNumber - lineOffset;
|
||||
const endLine = End.LineNumber - lineOffset;
|
||||
.filter(({ start, end }) => {
|
||||
const startLine = start.lineNumber - lineOffset;
|
||||
const endLine = end.lineNumber - lineOffset;
|
||||
|
||||
if (
|
||||
startLine < 1 ||
|
||||
|
|
@ -59,12 +59,12 @@ export const CodePreview = ({
|
|||
}
|
||||
return true;
|
||||
})
|
||||
.map(({ Start, End }) => {
|
||||
const startLine = Start.LineNumber - lineOffset;
|
||||
const endLine = End.LineNumber - lineOffset;
|
||||
.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;
|
||||
const from = document.line(startLine).from + start.column - 1;
|
||||
const to = document.line(endLine).from + end.column - 1;
|
||||
return markDecoration.range(from, to);
|
||||
})
|
||||
.sort((a, b) => a.from - b.from);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useMemo } from "react";
|
||||
import { CodePreview } from "./codePreview";
|
||||
import { SearchResultFile, SearchResultFileMatch } from "@/lib/types";
|
||||
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
||||
import { base64Decode } from "@/lib/utils";
|
||||
|
||||
|
||||
interface FileMatchProps {
|
||||
match: SearchResultFileMatch;
|
||||
match: SearchResultChunk;
|
||||
file: SearchResultFile;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
|
@ -18,11 +18,11 @@ export const FileMatch = ({
|
|||
onOpen,
|
||||
}: FileMatchProps) => {
|
||||
const content = useMemo(() => {
|
||||
return base64Decode(match.Content);
|
||||
}, [match.Content]);
|
||||
return base64Decode(match.content);
|
||||
}, [match.content]);
|
||||
|
||||
// If it's just the title, don't show a code preview
|
||||
if (match.FileName) {
|
||||
if (match.matchRanges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -40,9 +40,9 @@ export const FileMatch = ({
|
|||
>
|
||||
<CodePreview
|
||||
content={content}
|
||||
language={file.Language}
|
||||
ranges={match.Ranges}
|
||||
lineOffset={match.ContentStart.LineNumber - 1}
|
||||
language={file.language}
|
||||
ranges={match.matchRanges}
|
||||
lineOffset={match.contentStart.lineNumber - 1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Repository, SearchResultFile } from "@/lib/types";
|
||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FileMatch } from "./fileMatch";
|
||||
import { Repository, SearchResultFile } from "@/features/search/types";
|
||||
|
||||
export const MAX_MATCHES_TO_PREVIEW = 3;
|
||||
|
||||
|
|
@ -32,12 +32,12 @@ export const FileMatchContainer = ({
|
|||
}: FileMatchContainerProps) => {
|
||||
|
||||
const matchCount = useMemo(() => {
|
||||
return file.ChunkMatches.length;
|
||||
return file.chunks.length;
|
||||
}, [file]);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const sortedMatches = file.ChunkMatches.sort((a, b) => {
|
||||
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
|
||||
const sortedMatches = file.chunks.sort((a, b) => {
|
||||
return a.contentStart.lineNumber - b.contentStart.lineNumber;
|
||||
});
|
||||
|
||||
if (!showAllMatches) {
|
||||
|
|
@ -48,18 +48,16 @@ export const FileMatchContainer = ({
|
|||
}, [file, showAllMatches]);
|
||||
|
||||
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,
|
||||
}
|
||||
if (file.fileName.matchRanges.length > 0) {
|
||||
const range = file.fileName.matchRanges[0];
|
||||
return {
|
||||
from: range.start.column - 1,
|
||||
to: range.end.column - 1,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [matches]);
|
||||
}, [file.fileName.matchRanges]);
|
||||
|
||||
const isMoreContentButtonVisible = useMemo(() => {
|
||||
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
||||
|
|
@ -67,19 +65,19 @@ export const FileMatchContainer = ({
|
|||
|
||||
const onOpenMatch = useCallback((index: number) => {
|
||||
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
||||
return acc + match.Ranges.length;
|
||||
return acc + match.matchRanges.length;
|
||||
}, 0);
|
||||
onOpenFile();
|
||||
onMatchIndexChanged(matchIndex);
|
||||
}, [matches, onMatchIndexChanged, onOpenFile]);
|
||||
|
||||
const branches = useMemo(() => {
|
||||
if (!file.Branches) {
|
||||
if (!file.branches) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return file.Branches;
|
||||
}, [file.Branches]);
|
||||
return file.branches;
|
||||
}, [file.branches]);
|
||||
|
||||
const branchDisplayName = useMemo(() => {
|
||||
if (!isBranchFilteringEnabled || branches.length === 0) {
|
||||
|
|
@ -103,8 +101,8 @@ export const FileMatchContainer = ({
|
|||
}}
|
||||
>
|
||||
<FileHeader
|
||||
repo={repoMetadata[file.Repository]}
|
||||
fileName={file.FileName}
|
||||
repo={repoMetadata[file.repository]}
|
||||
fileName={file.fileName.text}
|
||||
fileNameHighlightRange={fileNameRange}
|
||||
branchDisplayName={branchDisplayName}
|
||||
branchDisplayTitle={branches.join(", ")}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Repository, SearchResultFile } from "@/lib/types";
|
||||
import { Repository, SearchResultFile } from "@/features/search/types";
|
||||
import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
|
@ -41,9 +41,8 @@ export const SearchResultsPanel = ({
|
|||
|
||||
// Quick guesstimation ;) This needs to be quick since the virtualizer will
|
||||
// run this upfront for all items in the list.
|
||||
const numCodeCells = fileMatch.ChunkMatches
|
||||
.filter(match => !match.FileName)
|
||||
.slice(0, showAllMatches ? fileMatch.ChunkMatches.length : MAX_MATCHES_TO_PREVIEW)
|
||||
const numCodeCells = fileMatch.chunks
|
||||
.slice(0, showAllMatches ? fileMatch.chunks.length : MAX_MATCHES_TO_PREVIEW)
|
||||
.length;
|
||||
|
||||
const estimatedSize =
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types";
|
||||
import { SearchQueryParams } from "@/lib/types";
|
||||
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -23,8 +23,9 @@ import { FilterPanel } from "./components/filterPanel";
|
|||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { Repository, SearchResultFile } from "@/features/search/types";
|
||||
|
||||
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
||||
const DEFAULT_MATCH_COUNT = 10000;
|
||||
|
||||
export default function SearchPage() {
|
||||
// We need a suspense boundary here since we are accessing query params
|
||||
|
|
@ -40,18 +41,20 @@ export default function SearchPage() {
|
|||
const SearchPageInternal = () => {
|
||||
const router = useRouter();
|
||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||
const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`);
|
||||
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
||||
const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`);
|
||||
const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches;
|
||||
const { setSearchHistory } = useSearchHistory();
|
||||
const captureEvent = useCaptureEvent();
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
|
||||
queryKey: ["search", searchQuery, maxMatchDisplayCount],
|
||||
queryKey: ["search", searchQuery, matches],
|
||||
queryFn: () => measure(() => unwrapServiceError(search({
|
||||
query: searchQuery,
|
||||
maxMatchDisplayCount,
|
||||
matches,
|
||||
contextLines: 3,
|
||||
whole: false,
|
||||
}, domain)), "client.search"),
|
||||
select: ({ data, durationMs }) => ({
|
||||
...data,
|
||||
|
|
@ -95,12 +98,11 @@ const SearchPageInternal = () => {
|
|||
queryKey: ["repos"],
|
||||
queryFn: () => getRepos(domain),
|
||||
select: (data): Record<string, Repository> =>
|
||||
data.List.Repos
|
||||
.map(r => r.Repository)
|
||||
data.repos
|
||||
.reduce(
|
||||
(acc, repo) => ({
|
||||
...acc,
|
||||
[repo.Name]: repo,
|
||||
[repo.name]: repo,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
|
|
@ -112,29 +114,29 @@ const SearchPageInternal = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const fileLanguages = searchResponse.Result.Files?.map(file => file.Language) || [];
|
||||
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
||||
|
||||
captureEvent("search_finished", {
|
||||
contentBytesLoaded: searchResponse.Result.ContentBytesLoaded,
|
||||
indexBytesLoaded: searchResponse.Result.IndexBytesLoaded,
|
||||
crashes: searchResponse.Result.Crashes,
|
||||
durationMs: searchResponse.Result.Duration / 1000000,
|
||||
fileCount: searchResponse.Result.FileCount,
|
||||
shardFilesConsidered: searchResponse.Result.ShardFilesConsidered,
|
||||
filesConsidered: searchResponse.Result.FilesConsidered,
|
||||
filesLoaded: searchResponse.Result.FilesLoaded,
|
||||
filesSkipped: searchResponse.Result.FilesSkipped,
|
||||
shardsScanned: searchResponse.Result.ShardsScanned,
|
||||
shardsSkipped: searchResponse.Result.ShardsSkipped,
|
||||
shardsSkippedFilter: searchResponse.Result.ShardsSkippedFilter,
|
||||
matchCount: searchResponse.Result.MatchCount,
|
||||
ngramMatches: searchResponse.Result.NgramMatches,
|
||||
ngramLookups: searchResponse.Result.NgramLookups,
|
||||
wait: searchResponse.Result.Wait,
|
||||
matchTreeConstruction: searchResponse.Result.MatchTreeConstruction,
|
||||
matchTreeSearch: searchResponse.Result.MatchTreeSearch,
|
||||
regexpsConsidered: searchResponse.Result.RegexpsConsidered,
|
||||
flushReason: searchResponse.Result.FlushReason,
|
||||
durationMs: searchResponse.durationMs,
|
||||
fileCount: searchResponse.zoektStats.fileCount,
|
||||
matchCount: searchResponse.zoektStats.matchCount,
|
||||
filesSkipped: searchResponse.zoektStats.filesSkipped,
|
||||
contentBytesLoaded: searchResponse.zoektStats.contentBytesLoaded,
|
||||
indexBytesLoaded: searchResponse.zoektStats.indexBytesLoaded,
|
||||
crashes: searchResponse.zoektStats.crashes,
|
||||
shardFilesConsidered: searchResponse.zoektStats.shardFilesConsidered,
|
||||
filesConsidered: searchResponse.zoektStats.filesConsidered,
|
||||
filesLoaded: searchResponse.zoektStats.filesLoaded,
|
||||
shardsScanned: searchResponse.zoektStats.shardsScanned,
|
||||
shardsSkipped: searchResponse.zoektStats.shardsSkipped,
|
||||
shardsSkippedFilter: searchResponse.zoektStats.shardsSkippedFilter,
|
||||
ngramMatches: searchResponse.zoektStats.ngramMatches,
|
||||
ngramLookups: searchResponse.zoektStats.ngramLookups,
|
||||
wait: searchResponse.zoektStats.wait,
|
||||
matchTreeConstruction: searchResponse.zoektStats.matchTreeConstruction,
|
||||
matchTreeSearch: searchResponse.zoektStats.matchTreeSearch,
|
||||
regexpsConsidered: searchResponse.zoektStats.regexpsConsidered,
|
||||
flushReason: searchResponse.zoektStats.flushReason,
|
||||
fileLanguages,
|
||||
});
|
||||
}, [captureEvent, searchQuery, searchResponse]);
|
||||
|
|
@ -151,24 +153,24 @@ const SearchPageInternal = () => {
|
|||
}
|
||||
|
||||
return {
|
||||
fileMatches: searchResponse.Result.Files ?? [],
|
||||
fileMatches: searchResponse.files ?? [],
|
||||
searchDurationMs: Math.round(searchResponse.durationMs),
|
||||
totalMatchCount: searchResponse.Result.MatchCount,
|
||||
totalMatchCount: searchResponse.zoektStats.matchCount,
|
||||
isBranchFilteringEnabled: searchResponse.isBranchFilteringEnabled,
|
||||
repoUrlTemplates: searchResponse.Result.RepoURLs,
|
||||
repoUrlTemplates: searchResponse.repoUrlTemplates,
|
||||
}
|
||||
}, [searchResponse]);
|
||||
|
||||
const isMoreResultsButtonVisible = useMemo(() => {
|
||||
return totalMatchCount > maxMatchDisplayCount;
|
||||
}, [totalMatchCount, maxMatchDisplayCount]);
|
||||
return totalMatchCount > matches;
|
||||
}, [totalMatchCount, matches]);
|
||||
|
||||
const numMatches = useMemo(() => {
|
||||
// Accumualtes the number of matches across all files
|
||||
return fileMatches.reduce(
|
||||
(acc, file) =>
|
||||
acc + file.ChunkMatches.reduce(
|
||||
(acc, chunk) => acc + chunk.Ranges.length,
|
||||
acc + file.chunks.reduce(
|
||||
(acc, chunk) => acc + chunk.matchRanges.length,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
|
|
@ -178,10 +180,10 @@ const SearchPageInternal = () => {
|
|||
const onLoadMoreResults = useCallback(() => {
|
||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||
[SearchQueryParams.query, searchQuery],
|
||||
[SearchQueryParams.maxMatchDisplayCount, `${maxMatchDisplayCount * 2}`],
|
||||
[SearchQueryParams.matches, `${matches * 2}`],
|
||||
)
|
||||
router.push(url);
|
||||
}, [maxMatchDisplayCount, router, searchQuery, domain]);
|
||||
}, [matches, router, searchQuery, domain]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-clip">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas";
|
||||
import { getVersionResponseSchema } from "@/lib/schemas";
|
||||
import { ServiceError } from "@/lib/serviceError";
|
||||
import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
|
||||
import { GetVersionResponse } from "@/lib/types";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import {
|
||||
FileSourceResponse,
|
||||
FileSourceRequest,
|
||||
ListRepositoriesResponse,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
} from "@/features/search/types";
|
||||
import {
|
||||
fileSourceResponseSchema,
|
||||
listRepositoriesResponseSchema,
|
||||
searchResponseSchema,
|
||||
} from "@/features/search/schemas";
|
||||
|
||||
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse | ServiceError> => {
|
||||
const result = await fetch("/api/search", {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use server';
|
||||
|
||||
import { listRepositories } from "@/lib/server/searchService";
|
||||
import { listRepositories } from "@/features/search/listReposApi";
|
||||
import { NextRequest } from "next/server";
|
||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use server';
|
||||
|
||||
import { search } from "@/lib/server/searchService";
|
||||
import { searchRequestSchema } from "@/lib/schemas";
|
||||
import { search } from "@/features/search/searchApi";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||
import { SearchRequest } from "@/lib/types";
|
||||
import { searchRequestSchema } from "@/features/search/schemas";
|
||||
import { SearchRequest } from "@/features/search/types";
|
||||
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const domain = request.headers.get("X-Org-Domain")!;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use server';
|
||||
|
||||
import { fileSourceRequestSchema } from "@/lib/schemas";
|
||||
import { getFileSource } from "@/lib/server/searchService";
|
||||
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||
import { FileSourceRequest } from "@/lib/types";
|
||||
import { fileSourceRequestSchema } from "@/features/search/schemas";
|
||||
import { FileSourceRequest } from "@/features/search/types";
|
||||
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const body = await request.json();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const planLabels = {
|
||||
oss: "OSS",
|
||||
"cloud:team": "Team",
|
||||
|
|
@ -7,6 +8,7 @@ const planLabels = {
|
|||
export type Plan = keyof typeof planLabels;
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const entitlements = [
|
||||
"search-contexts",
|
||||
"billing"
|
||||
|
|
|
|||
42
packages/web/src/features/search/fileSourceApi.ts
Normal file
42
packages/web/src/features/search/fileSourceApi.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { fileNotFound, ServiceError } from "../../lib/serviceError";
|
||||
import { FileSourceRequest, FileSourceResponse } from "./types";
|
||||
import { isServiceError } from "../../lib/utils";
|
||||
import { search } from "./searchApi";
|
||||
|
||||
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
|
||||
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
||||
// by zoekt.
|
||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, orgId: number): Promise<FileSourceResponse | ServiceError> => {
|
||||
const escapedFileName = escapeStringRegexp(fileName);
|
||||
const escapedRepository = escapeStringRegexp(repository);
|
||||
|
||||
let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
|
||||
if (branch) {
|
||||
query = query.concat(` branch:${branch}`);
|
||||
}
|
||||
|
||||
const searchResponse = await search({
|
||||
query,
|
||||
matches: 1,
|
||||
whole: true,
|
||||
}, orgId);
|
||||
|
||||
if (isServiceError(searchResponse)) {
|
||||
return searchResponse;
|
||||
}
|
||||
|
||||
const files = searchResponse.files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return fileNotFound(fileName, repository);
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const source = file.content ?? '';
|
||||
const language = file.language;
|
||||
return {
|
||||
source,
|
||||
language,
|
||||
} satisfies FileSourceResponse;
|
||||
}
|
||||
44
packages/web/src/features/search/listReposApi.ts
Normal file
44
packages/web/src/features/search/listReposApi.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
|
||||
import { ListRepositoriesResponse } from "./types";
|
||||
import { zoektFetch } from "./zoektClient";
|
||||
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
|
||||
|
||||
|
||||
export const listRepositories = async (orgId: number): Promise<ListRepositoriesResponse | ServiceError> => {
|
||||
const body = JSON.stringify({
|
||||
opts: {
|
||||
Field: 0,
|
||||
}
|
||||
});
|
||||
|
||||
let header: Record<string, string> = {};
|
||||
header = {
|
||||
"X-Tenant-ID": orgId.toString()
|
||||
};
|
||||
|
||||
const listResponse = await zoektFetch({
|
||||
path: "/api/list",
|
||||
body,
|
||||
header,
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
return invalidZoektResponse(listResponse);
|
||||
}
|
||||
|
||||
const listBody = await listResponse.json();
|
||||
|
||||
const parser = zoektListRepositoriesResponseSchema.transform(({ List }) => ({
|
||||
repos: List.Repos.map((repo) => ({
|
||||
name: repo.Repository.Name,
|
||||
url: repo.Repository.URL,
|
||||
source: repo.Repository.Source,
|
||||
branches: repo.Repository.Branches?.map((branch) => branch.Name) ?? [],
|
||||
rawConfig: repo.Repository.RawConfig ?? undefined,
|
||||
}))
|
||||
} satisfies ListRepositoriesResponse));
|
||||
|
||||
return parser.parse(listBody);
|
||||
}
|
||||
104
packages/web/src/features/search/schemas.ts
Normal file
104
packages/web/src/features/search/schemas.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { z } from "zod";
|
||||
|
||||
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
|
||||
lineNumber: z.number(),
|
||||
// 1-based column number (in runes) from the beginning of line
|
||||
column: z.number(),
|
||||
});
|
||||
|
||||
export const rangeSchema = z.object({
|
||||
start: locationSchema,
|
||||
end: locationSchema,
|
||||
});
|
||||
|
||||
export const symbolSchema = z.object({
|
||||
symbol: z.string(),
|
||||
kind: z.string(),
|
||||
});
|
||||
|
||||
export const searchRequestSchema = z.object({
|
||||
// The zoekt query to execute.
|
||||
query: z.string(),
|
||||
// The number of matches to return.
|
||||
matches: z.number(),
|
||||
// The number of context lines to return.
|
||||
contextLines: z.number().optional(),
|
||||
// Whether to return the whole file as part of the response.
|
||||
whole: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const searchResponseSchema = z.object({
|
||||
zoektStats: z.object({
|
||||
// The duration (in nanoseconds) of the search.
|
||||
duration: z.number(),
|
||||
fileCount: z.number(),
|
||||
matchCount: z.number(),
|
||||
filesSkipped: z.number(),
|
||||
contentBytesLoaded: z.number(),
|
||||
indexBytesLoaded: z.number(),
|
||||
crashes: z.number(),
|
||||
shardFilesConsidered: z.number(),
|
||||
filesConsidered: z.number(),
|
||||
filesLoaded: z.number(),
|
||||
shardsScanned: z.number(),
|
||||
shardsSkipped: z.number(),
|
||||
shardsSkippedFilter: z.number(),
|
||||
ngramMatches: z.number(),
|
||||
ngramLookups: z.number(),
|
||||
wait: z.number(),
|
||||
matchTreeConstruction: z.number(),
|
||||
matchTreeSearch: z.number(),
|
||||
regexpsConsidered: z.number(),
|
||||
flushReason: z.number(),
|
||||
}),
|
||||
files: z.array(z.object({
|
||||
fileName: z.object({
|
||||
// The name of the file
|
||||
text: z.string(),
|
||||
// Any matching ranges
|
||||
matchRanges: z.array(rangeSchema),
|
||||
}),
|
||||
repository: z.string(),
|
||||
language: z.string(),
|
||||
chunks: z.array(z.object({
|
||||
content: z.string(),
|
||||
matchRanges: z.array(rangeSchema),
|
||||
contentStart: locationSchema,
|
||||
symbols: z.array(z.object({
|
||||
...symbolSchema.shape,
|
||||
parent: symbolSchema.optional(),
|
||||
})).optional(),
|
||||
})),
|
||||
branches: z.array(z.string()).optional(),
|
||||
// Set if `whole` is true.
|
||||
content: z.string().optional(),
|
||||
})),
|
||||
repoUrlTemplates: z.record(z.string(), z.string()),
|
||||
isBranchFilteringEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const repositorySchema = z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
source: z.string(),
|
||||
branches: z.array(z.string()),
|
||||
rawConfig: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const listRepositoriesResponseSchema = z.object({
|
||||
repos: z.array(repositorySchema),
|
||||
});
|
||||
|
||||
export const fileSourceRequestSchema = z.object({
|
||||
fileName: z.string(),
|
||||
repository: z.string(),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
export const fileSourceResponseSchema = z.object({
|
||||
source: z.string(),
|
||||
language: z.string(),
|
||||
});
|
||||
230
packages/web/src/features/search/searchApi.ts
Normal file
230
packages/web/src/features/search/searchApi.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { env } from "@/env.mjs";
|
||||
import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
|
||||
import { isServiceError } from "../../lib/utils";
|
||||
import { zoektFetch } from "./zoektClient";
|
||||
import { prisma } from "@/prisma";
|
||||
import { ErrorCode } from "../../lib/errorCodes";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { zoektSearchResponseSchema } from "./zoektSchema";
|
||||
import { SearchRequest, SearchResponse, SearchResultRange } from "./types";
|
||||
|
||||
// List of supported query prefixes in zoekt.
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
||||
enum zoektPrefixes {
|
||||
archived = "archived:",
|
||||
branchShort = "b:",
|
||||
branch = "branch:",
|
||||
caseShort = "c:",
|
||||
case = "case:",
|
||||
content = "content:",
|
||||
fileShort = "f:",
|
||||
file = "file:",
|
||||
fork = "fork:",
|
||||
public = "public:",
|
||||
repoShort = "r:",
|
||||
repo = "repo:",
|
||||
regex = "regex:",
|
||||
lang = "lang:",
|
||||
sym = "sym:",
|
||||
typeShort = "t:",
|
||||
type = "type:",
|
||||
reposet = "reposet:",
|
||||
}
|
||||
|
||||
const transformZoektQuery = async (query: string, orgId: number): Promise<string | ServiceError> => {
|
||||
const prevQueryParts = query.split(" ");
|
||||
const newQueryParts = [];
|
||||
|
||||
for (const part of prevQueryParts) {
|
||||
|
||||
// Handle mapping `rev:` and `revision:` to `branch:`
|
||||
if (part.match(/^-?(rev|revision):.+$/)) {
|
||||
const isNegated = part.startsWith("-");
|
||||
let revisionName = part.slice(part.indexOf(":") + 1);
|
||||
|
||||
// Special case: `*` -> search all revisions.
|
||||
// In zoekt, providing a blank string will match all branches.
|
||||
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/eval.go#L560-L562
|
||||
if (revisionName === "*") {
|
||||
revisionName = "";
|
||||
}
|
||||
newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.branch}${revisionName}`);
|
||||
}
|
||||
|
||||
// Expand `context:` into `reposet:` atom.
|
||||
else if (part.match(/^-?context:.+$/)) {
|
||||
const isNegated = part.startsWith("-");
|
||||
const contextName = part.slice(part.indexOf(":") + 1);
|
||||
|
||||
const context = await prisma.searchContext.findUnique({
|
||||
where: {
|
||||
name_orgId: {
|
||||
name: contextName,
|
||||
orgId,
|
||||
}
|
||||
},
|
||||
include: {
|
||||
repos: true,
|
||||
}
|
||||
});
|
||||
|
||||
// If the context doesn't exist, return an error.
|
||||
if (!context) {
|
||||
return {
|
||||
errorCode: ErrorCode.SEARCH_CONTEXT_NOT_FOUND,
|
||||
message: `Search context "${contextName}" not found`,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const names = context.repos.map((repo) => repo.name);
|
||||
newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.reposet}${names.join(",")}`);
|
||||
}
|
||||
|
||||
// no-op: add the original part to the new query parts.
|
||||
else {
|
||||
newQueryParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return newQueryParts.join(" ");
|
||||
}
|
||||
|
||||
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, orgId: number) => {
|
||||
const transformedQuery = await transformZoektQuery(query, orgId);
|
||||
if (isServiceError(transformedQuery)) {
|
||||
return transformedQuery;
|
||||
}
|
||||
query = transformedQuery;
|
||||
|
||||
const isBranchFilteringEnabled = (
|
||||
query.includes(zoektPrefixes.branch) ||
|
||||
query.includes(zoektPrefixes.branchShort)
|
||||
);
|
||||
|
||||
// We only want to show matches for the default branch when
|
||||
// the user isn't explicitly filtering by branch.
|
||||
if (!isBranchFilteringEnabled) {
|
||||
query = query.concat(` branch:HEAD`);
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
q: query,
|
||||
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892
|
||||
opts: {
|
||||
ChunkMatches: true,
|
||||
MaxMatchDisplayCount: matches,
|
||||
NumContextLines: contextLines,
|
||||
Whole: !!whole,
|
||||
TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT,
|
||||
ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT,
|
||||
MaxWallTime: env.ZOEKT_MAX_WALL_TIME_MS * 1000 * 1000, // zoekt expects a duration in nanoseconds
|
||||
}
|
||||
});
|
||||
|
||||
let header: Record<string, string> = {};
|
||||
header = {
|
||||
"X-Tenant-ID": orgId.toString()
|
||||
};
|
||||
|
||||
const searchResponse = await zoektFetch({
|
||||
path: "/api/search",
|
||||
body,
|
||||
header,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
return invalidZoektResponse(searchResponse);
|
||||
}
|
||||
|
||||
const searchBody = await searchResponse.json();
|
||||
|
||||
const parser = zoektSearchResponseSchema.transform(({ Result }) => ({
|
||||
zoektStats: {
|
||||
duration: Result.Duration,
|
||||
fileCount: Result.FileCount,
|
||||
matchCount: Result.MatchCount,
|
||||
filesSkipped: Result.FilesSkipped,
|
||||
contentBytesLoaded: Result.ContentBytesLoaded,
|
||||
indexBytesLoaded: Result.IndexBytesLoaded,
|
||||
crashes: Result.Crashes,
|
||||
shardFilesConsidered: Result.ShardFilesConsidered,
|
||||
filesConsidered: Result.FilesConsidered,
|
||||
filesLoaded: Result.FilesLoaded,
|
||||
shardsScanned: Result.ShardsScanned,
|
||||
shardsSkipped: Result.ShardsSkipped,
|
||||
shardsSkippedFilter: Result.ShardsSkippedFilter,
|
||||
ngramMatches: Result.NgramMatches,
|
||||
ngramLookups: Result.NgramLookups,
|
||||
wait: Result.Wait,
|
||||
matchTreeConstruction: Result.MatchTreeConstruction,
|
||||
matchTreeSearch: Result.MatchTreeSearch,
|
||||
regexpsConsidered: Result.RegexpsConsidered,
|
||||
flushReason: Result.FlushReason,
|
||||
},
|
||||
files: Result.Files?.map((file) => {
|
||||
const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName);
|
||||
return {
|
||||
fileName: {
|
||||
text: file.FileName,
|
||||
matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({
|
||||
start: {
|
||||
byteOffset: range.Start.ByteOffset,
|
||||
column: range.Start.Column,
|
||||
lineNumber: range.Start.LineNumber,
|
||||
},
|
||||
end: {
|
||||
byteOffset: range.End.ByteOffset,
|
||||
column: range.End.Column,
|
||||
lineNumber: range.End.LineNumber,
|
||||
}
|
||||
})) : [],
|
||||
},
|
||||
repository: file.Repository,
|
||||
language: file.Language,
|
||||
chunks: file.ChunkMatches
|
||||
.filter((chunk) => !chunk.FileName) // Filter out filename chunks.
|
||||
.map((chunk) => {
|
||||
return {
|
||||
content: chunk.Content,
|
||||
matchRanges: chunk.Ranges.map((range) => ({
|
||||
start: {
|
||||
byteOffset: range.Start.ByteOffset,
|
||||
column: range.Start.Column,
|
||||
lineNumber: range.Start.LineNumber,
|
||||
},
|
||||
end: {
|
||||
byteOffset: range.End.ByteOffset,
|
||||
column: range.End.Column,
|
||||
lineNumber: range.End.LineNumber,
|
||||
}
|
||||
}) satisfies SearchResultRange),
|
||||
contentStart: {
|
||||
byteOffset: chunk.ContentStart.ByteOffset,
|
||||
column: chunk.ContentStart.Column,
|
||||
lineNumber: chunk.ContentStart.LineNumber,
|
||||
},
|
||||
symbols: chunk.SymbolInfo?.map((symbol) => {
|
||||
return {
|
||||
symbol: symbol.Sym,
|
||||
kind: symbol.Kind,
|
||||
parent: symbol.Parent.length > 0 ? {
|
||||
symbol: symbol.Parent,
|
||||
kind: symbol.ParentKind,
|
||||
} : undefined,
|
||||
}
|
||||
}) ?? undefined,
|
||||
}
|
||||
}),
|
||||
branches: file.Branches,
|
||||
content: file.Content,
|
||||
}
|
||||
}
|
||||
) ?? [],
|
||||
repoUrlTemplates: Result.RepoURLs,
|
||||
isBranchFilteringEnabled: isBranchFilteringEnabled,
|
||||
} satisfies SearchResponse));
|
||||
|
||||
return parser.parse(searchBody);
|
||||
}
|
||||
25
packages/web/src/features/search/types.ts
Normal file
25
packages/web/src/features/search/types.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
fileSourceResponseSchema,
|
||||
listRepositoriesResponseSchema,
|
||||
locationSchema,
|
||||
searchRequestSchema,
|
||||
searchResponseSchema,
|
||||
rangeSchema,
|
||||
fileSourceRequestSchema,
|
||||
symbolSchema,
|
||||
} from "./schemas";
|
||||
import { z } from "zod";
|
||||
|
||||
export type SearchRequest = z.infer<typeof searchRequestSchema>;
|
||||
export type SearchResponse = z.infer<typeof searchResponseSchema>;
|
||||
export type SearchResultRange = z.infer<typeof rangeSchema>;
|
||||
export type SearchResultLocation = z.infer<typeof locationSchema>;
|
||||
export type SearchResultFile = SearchResponse["files"][number];
|
||||
export type SearchResultChunk = SearchResultFile["chunks"][number];
|
||||
export type SearchSymbol = z.infer<typeof symbolSchema>;
|
||||
|
||||
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
||||
export type Repository = ListRepositoriesResponse["repos"][number];
|
||||
|
||||
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
|
||||
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
||||
132
packages/web/src/features/search/zoektSchema.ts
Normal file
132
packages/web/src/features/search/zoektSchema.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
|
||||
import { z } from "zod";
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L212
|
||||
export const zoektLocationSchema = 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(),
|
||||
});
|
||||
|
||||
export const zoektRangeSchema = z.object({
|
||||
Start: zoektLocationSchema,
|
||||
End: zoektLocationSchema,
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L350
|
||||
export const zoektSearchResponseStats = {
|
||||
ContentBytesLoaded: z.number(),
|
||||
IndexBytesLoaded: z.number(),
|
||||
Crashes: z.number(),
|
||||
Duration: z.number(),
|
||||
FileCount: z.number(),
|
||||
ShardFilesConsidered: z.number(),
|
||||
FilesConsidered: z.number(),
|
||||
FilesLoaded: z.number(),
|
||||
FilesSkipped: z.number(),
|
||||
ShardsScanned: z.number(),
|
||||
ShardsSkipped: z.number(),
|
||||
ShardsSkippedFilter: z.number(),
|
||||
MatchCount: z.number(),
|
||||
NgramMatches: z.number(),
|
||||
NgramLookups: z.number(),
|
||||
Wait: z.number(),
|
||||
MatchTreeConstruction: z.number(),
|
||||
MatchTreeSearch: z.number(),
|
||||
RegexpsConsidered: z.number(),
|
||||
FlushReason: z.number(),
|
||||
}
|
||||
|
||||
export const zoektSymbolSchema = z.object({
|
||||
Sym: z.string(),
|
||||
Kind: z.string(),
|
||||
Parent: z.string(),
|
||||
ParentKind: z.string(),
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L497
|
||||
export const zoektSearchResponseSchema = z.object({
|
||||
Result: z.object({
|
||||
...zoektSearchResponseStats,
|
||||
Files: z.array(z.object({
|
||||
FileName: z.string(),
|
||||
Repository: z.string(),
|
||||
Version: z.string().optional(),
|
||||
Language: z.string(),
|
||||
Branches: z.array(z.string()).optional(),
|
||||
ChunkMatches: z.array(z.object({
|
||||
Content: z.string(),
|
||||
Ranges: z.array(zoektRangeSchema),
|
||||
FileName: z.boolean(),
|
||||
ContentStart: zoektLocationSchema,
|
||||
Score: z.number(),
|
||||
SymbolInfo: z.array(zoektSymbolSchema).nullable(),
|
||||
})),
|
||||
Checksum: z.string(),
|
||||
Score: z.number(),
|
||||
// Set if `whole` is true.
|
||||
Content: z.string().optional(),
|
||||
})).nullable(),
|
||||
RepoURLs: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
|
||||
const zoektRepoStatsSchema = z.object({
|
||||
Repos: z.number(),
|
||||
Shards: z.number(),
|
||||
Documents: z.number(),
|
||||
IndexBytes: z.number(),
|
||||
ContentBytes: z.number(),
|
||||
NewLinesCount: z.number(),
|
||||
DefaultBranchNewLinesCount: z.number(),
|
||||
OtherBranchesNewLinesCount: z.number(),
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L716
|
||||
const zoektIndexMetadataSchema = z.object({
|
||||
IndexFormatVersion: z.number(),
|
||||
IndexFeatureVersion: z.number(),
|
||||
IndexMinReaderVersion: z.number(),
|
||||
IndexTime: z.string(),
|
||||
PlainASCII: z.boolean(),
|
||||
LanguageMap: z.record(z.string(), z.number()),
|
||||
ZoektVersion: z.string(),
|
||||
ID: z.string(),
|
||||
});
|
||||
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L555
|
||||
export const zoektRepositorySchema = z.object({
|
||||
Name: z.string(),
|
||||
URL: z.string(),
|
||||
Source: z.string(),
|
||||
Branches: z.array(z.object({
|
||||
Name: z.string(),
|
||||
Version: z.string(),
|
||||
})).nullable(),
|
||||
CommitURLTemplate: z.string(),
|
||||
FileURLTemplate: z.string(),
|
||||
LineFragmentTemplate: z.string(),
|
||||
RawConfig: z.record(z.string(), z.string()).nullable(),
|
||||
Rank: z.number(),
|
||||
IndexOptions: z.string(),
|
||||
HasSymbols: z.boolean(),
|
||||
Tombstone: z.boolean(),
|
||||
LatestCommitDate: z.string(),
|
||||
FileTombstones: z.string().optional(),
|
||||
});
|
||||
|
||||
export const zoektListRepositoriesResponseSchema = z.object({
|
||||
List: z.object({
|
||||
Repos: z.array(z.object({
|
||||
Repository: zoektRepositorySchema,
|
||||
IndexMetadata: zoektIndexMetadataSchema,
|
||||
Stats: zoektRepoStatsSchema,
|
||||
})),
|
||||
Stats: zoektRepoStatsSchema,
|
||||
})
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||
import { SearchResultRange } from "../types";
|
||||
import { SearchResultRange } from "@/features/search/types";
|
||||
|
||||
const setMatchState = StateEffect.define<{
|
||||
selectedMatchIndex: number,
|
||||
|
|
@ -8,9 +8,9 @@ const setMatchState = StateEffect.define<{
|
|||
}>();
|
||||
|
||||
const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => {
|
||||
const { Start, End } = range;
|
||||
const from = document.line(Start.LineNumber).from + Start.Column - 1;
|
||||
const to = document.line(End.LineNumber).from + End.Column - 1;
|
||||
const { start, end } = range;
|
||||
const from = document.line(start.lineNumber).from + start.column - 1;
|
||||
const to = document.line(end.lineNumber).from + end.column - 1;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ const matchHighlighter = StateField.define<DecorationSet>({
|
|||
|
||||
const decorations = ranges
|
||||
.sort((a, b) => {
|
||||
return a.Start.ByteOffset - b.Start.ByteOffset;
|
||||
return a.start.byteOffset - b.start.byteOffset;
|
||||
})
|
||||
.map((range, index) => {
|
||||
const { from, to } = convertToCodeMirrorRange(range, transaction.newDoc);
|
||||
|
|
|
|||
|
|
@ -3,103 +3,6 @@ import { RepoIndexingStatus } from "@sourcebot/db";
|
|||
import { z } from "zod";
|
||||
import { isServiceError } from "./utils";
|
||||
|
||||
export const searchRequestSchema = z.object({
|
||||
query: z.string(),
|
||||
maxMatchDisplayCount: z.number(),
|
||||
whole: z.boolean().optional(),
|
||||
});
|
||||
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L212
|
||||
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
|
||||
LineNumber: z.number(),
|
||||
// 1-based column number (in runes) from the beginning of line
|
||||
Column: z.number(),
|
||||
});
|
||||
|
||||
export const rangeSchema = z.object({
|
||||
Start: locationSchema,
|
||||
End: locationSchema,
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L350
|
||||
export const searchResponseStats = {
|
||||
ContentBytesLoaded: z.number(),
|
||||
IndexBytesLoaded: z.number(),
|
||||
Crashes: z.number(),
|
||||
Duration: z.number(),
|
||||
FileCount: z.number(),
|
||||
ShardFilesConsidered: z.number(),
|
||||
FilesConsidered: z.number(),
|
||||
FilesLoaded: z.number(),
|
||||
FilesSkipped: z.number(),
|
||||
ShardsScanned: z.number(),
|
||||
ShardsSkipped: z.number(),
|
||||
ShardsSkippedFilter: z.number(),
|
||||
MatchCount: z.number(),
|
||||
NgramMatches: z.number(),
|
||||
NgramLookups: z.number(),
|
||||
Wait: z.number(),
|
||||
MatchTreeConstruction: z.number(),
|
||||
MatchTreeSearch: z.number(),
|
||||
RegexpsConsidered: z.number(),
|
||||
FlushReason: z.number(),
|
||||
}
|
||||
|
||||
export const symbolSchema = z.object({
|
||||
Sym: z.string(),
|
||||
Kind: z.string(),
|
||||
Parent: z.string(),
|
||||
ParentKind: z.string(),
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L497
|
||||
export const zoektSearchResponseSchema = z.object({
|
||||
Result: z.object({
|
||||
...searchResponseStats,
|
||||
Files: z.array(z.object({
|
||||
FileName: z.string(),
|
||||
Repository: z.string(),
|
||||
Version: z.string().optional(),
|
||||
Language: z.string(),
|
||||
Branches: z.array(z.string()).optional(),
|
||||
ChunkMatches: z.array(z.object({
|
||||
Content: z.string(),
|
||||
Ranges: z.array(rangeSchema),
|
||||
FileName: z.boolean(),
|
||||
ContentStart: locationSchema,
|
||||
Score: z.number(),
|
||||
SymbolInfo: z.array(symbolSchema).nullable(),
|
||||
})),
|
||||
Checksum: z.string(),
|
||||
Score: z.number(),
|
||||
// Set if `whole` is true.
|
||||
Content: z.string().optional(),
|
||||
})).nullable(),
|
||||
RepoURLs: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const searchResponseSchema = z.object({
|
||||
...zoektSearchResponseSchema.shape,
|
||||
// Flag when a branch filter was used (e.g., `branch:`, `revision:`, etc.).
|
||||
isBranchFilteringEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const fileSourceRequestSchema = z.object({
|
||||
fileName: z.string(),
|
||||
repository: z.string(),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
export const fileSourceResponseSchema = z.object({
|
||||
source: z.string(),
|
||||
language: z.string(),
|
||||
});
|
||||
|
||||
export const secretCreateRequestSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
|
|
@ -109,62 +12,6 @@ export const secreteDeleteRequestSchema = z.object({
|
|||
key: z.string(),
|
||||
});
|
||||
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
|
||||
const repoStatsSchema = z.object({
|
||||
Repos: z.number(),
|
||||
Shards: z.number(),
|
||||
Documents: z.number(),
|
||||
IndexBytes: z.number(),
|
||||
ContentBytes: z.number(),
|
||||
NewLinesCount: z.number(),
|
||||
DefaultBranchNewLinesCount: z.number(),
|
||||
OtherBranchesNewLinesCount: z.number(),
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L716
|
||||
const indexMetadataSchema = z.object({
|
||||
IndexFormatVersion: z.number(),
|
||||
IndexFeatureVersion: z.number(),
|
||||
IndexMinReaderVersion: z.number(),
|
||||
IndexTime: z.string(),
|
||||
PlainASCII: z.boolean(),
|
||||
LanguageMap: z.record(z.string(), z.number()),
|
||||
ZoektVersion: z.string(),
|
||||
ID: z.string(),
|
||||
});
|
||||
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L555
|
||||
export const repositorySchema = z.object({
|
||||
Name: z.string(),
|
||||
URL: z.string(),
|
||||
Source: z.string(),
|
||||
Branches: z.array(z.object({
|
||||
Name: z.string(),
|
||||
Version: z.string(),
|
||||
})).nullable(),
|
||||
CommitURLTemplate: z.string(),
|
||||
FileURLTemplate: z.string(),
|
||||
LineFragmentTemplate: z.string(),
|
||||
RawConfig: z.record(z.string(), z.string()).nullable(),
|
||||
Rank: z.number(),
|
||||
IndexOptions: z.string(),
|
||||
HasSymbols: z.boolean(),
|
||||
Tombstone: z.boolean(),
|
||||
LatestCommitDate: z.string(),
|
||||
FileTombstones: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listRepositoriesResponseSchema = z.object({
|
||||
List: z.object({
|
||||
Repos: z.array(z.object({
|
||||
Repository: repositorySchema,
|
||||
IndexMetadata: indexMetadataSchema,
|
||||
Stats: repoStatsSchema,
|
||||
})),
|
||||
Stats: repoStatsSchema,
|
||||
})
|
||||
});
|
||||
export const repositoryQuerySchema = z.object({
|
||||
codeHostType: z.string(),
|
||||
repoId: z.number(),
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { env } from "@/env.mjs";
|
||||
import { listRepositoriesResponseSchema, zoektSearchResponseSchema } 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";
|
||||
import { prisma } from "@/prisma";
|
||||
import { ErrorCode } from "../errorCodes";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
// List of supported query prefixes in zoekt.
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
||||
enum zoektPrefixes {
|
||||
archived = "archived:",
|
||||
branchShort = "b:",
|
||||
branch = "branch:",
|
||||
caseShort = "c:",
|
||||
case = "case:",
|
||||
content = "content:",
|
||||
fileShort = "f:",
|
||||
file = "file:",
|
||||
fork = "fork:",
|
||||
public = "public:",
|
||||
repoShort = "r:",
|
||||
repo = "repo:",
|
||||
regex = "regex:",
|
||||
lang = "lang:",
|
||||
sym = "sym:",
|
||||
typeShort = "t:",
|
||||
type = "type:",
|
||||
reposet = "reposet:",
|
||||
}
|
||||
|
||||
const transformZoektQuery = async (query: string, orgId: number): Promise<string | ServiceError> => {
|
||||
const prevQueryParts = query.split(" ");
|
||||
const newQueryParts = [];
|
||||
|
||||
for (const part of prevQueryParts) {
|
||||
|
||||
// Handle mapping `rev:` and `revision:` to `branch:`
|
||||
if (part.match(/^-?(rev|revision):.+$/)) {
|
||||
const isNegated = part.startsWith("-");
|
||||
let revisionName = part.slice(part.indexOf(":") + 1);
|
||||
|
||||
// Special case: `*` -> search all revisions.
|
||||
// In zoekt, providing a blank string will match all branches.
|
||||
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/eval.go#L560-L562
|
||||
if (revisionName === "*") {
|
||||
revisionName = "";
|
||||
}
|
||||
newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.branch}${revisionName}`);
|
||||
}
|
||||
|
||||
// Expand `context:` into `reposet:` atom.
|
||||
else if (part.match(/^-?context:.+$/)) {
|
||||
const isNegated = part.startsWith("-");
|
||||
const contextName = part.slice(part.indexOf(":") + 1);
|
||||
|
||||
const context = await prisma.searchContext.findUnique({
|
||||
where: {
|
||||
name_orgId: {
|
||||
name: contextName,
|
||||
orgId,
|
||||
}
|
||||
},
|
||||
include: {
|
||||
repos: true,
|
||||
}
|
||||
});
|
||||
|
||||
// If the context doesn't exist, return an error.
|
||||
if (!context) {
|
||||
return {
|
||||
errorCode: ErrorCode.SEARCH_CONTEXT_NOT_FOUND,
|
||||
message: `Search context "${contextName}" not found`,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const names = context.repos.map((repo) => repo.name);
|
||||
newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.reposet}${names.join(",")}`);
|
||||
}
|
||||
|
||||
// no-op: add the original part to the new query parts.
|
||||
else {
|
||||
newQueryParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return newQueryParts.join(" ");
|
||||
}
|
||||
|
||||
export const search = async ({ query, maxMatchDisplayCount, whole }: SearchRequest, orgId: number): Promise<SearchResponse | ServiceError> => {
|
||||
const transformedQuery = await transformZoektQuery(query, orgId);
|
||||
if (isServiceError(transformedQuery)) {
|
||||
return transformedQuery;
|
||||
}
|
||||
query = transformedQuery;
|
||||
|
||||
const isBranchFilteringEnabled = (
|
||||
query.includes(zoektPrefixes.branch) ||
|
||||
query.includes(zoektPrefixes.branchShort)
|
||||
);
|
||||
|
||||
// We only want to show matches for the default branch when
|
||||
// the user isn't explicitly filtering by branch.
|
||||
if (!isBranchFilteringEnabled) {
|
||||
query = query.concat(` branch:HEAD`);
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
q: query,
|
||||
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892
|
||||
opts: {
|
||||
NumContextLines: 2,
|
||||
ChunkMatches: true,
|
||||
MaxMatchDisplayCount: maxMatchDisplayCount,
|
||||
Whole: !!whole,
|
||||
ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT,
|
||||
TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT,
|
||||
MaxWallTime: env.ZOEKT_MAX_WALL_TIME_MS * 1000 * 1000, // zoekt expects a duration in nanoseconds
|
||||
}
|
||||
});
|
||||
|
||||
let header: Record<string, string> = {};
|
||||
header = {
|
||||
"X-Tenant-ID": orgId.toString()
|
||||
};
|
||||
|
||||
const searchResponse = await zoektFetch({
|
||||
path: "/api/search",
|
||||
body,
|
||||
header,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
return invalidZoektResponse(searchResponse);
|
||||
}
|
||||
|
||||
const searchBody = await searchResponse.json();
|
||||
const parsedSearchResponse = zoektSearchResponseSchema.safeParse(searchBody);
|
||||
if (!parsedSearchResponse.success) {
|
||||
console.error(`Failed to parse zoekt response. Error: ${parsedSearchResponse.error}`);
|
||||
return unexpectedError(`Something went wrong while parsing the response from zoekt`);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsedSearchResponse.data,
|
||||
isBranchFilteringEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
|
||||
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
||||
// by zoekt.
|
||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, orgId: number): Promise<FileSourceResponse | ServiceError> => {
|
||||
const escapedFileName = escapeStringRegexp(fileName);
|
||||
const escapedRepository = escapeStringRegexp(repository);
|
||||
|
||||
let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
|
||||
if (branch) {
|
||||
query = query.concat(` branch:${branch}`);
|
||||
}
|
||||
|
||||
const searchResponse = await search({
|
||||
query,
|
||||
maxMatchDisplayCount: 1,
|
||||
whole: true,
|
||||
}, orgId);
|
||||
|
||||
if (isServiceError(searchResponse)) {
|
||||
return searchResponse;
|
||||
}
|
||||
|
||||
const files = searchResponse.Result.Files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return fileNotFound(fileName, repository);
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const source = file.Content ?? '';
|
||||
const language = file.Language;
|
||||
return {
|
||||
source,
|
||||
language,
|
||||
}
|
||||
}
|
||||
|
||||
export const listRepositories = async (orgId: number): Promise<ListRepositoriesResponse | ServiceError> => {
|
||||
const body = JSON.stringify({
|
||||
opts: {
|
||||
Field: 0,
|
||||
}
|
||||
});
|
||||
|
||||
let header: Record<string, string> = {};
|
||||
header = {
|
||||
"X-Tenant-ID": orgId.toString()
|
||||
};
|
||||
|
||||
const listResponse = await zoektFetch({
|
||||
path: "/api/list",
|
||||
body,
|
||||
header,
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
return invalidZoektResponse(listResponse);
|
||||
}
|
||||
|
||||
const listBody = await listResponse.json();
|
||||
const parsedListResponse = listRepositoriesResponseSchema.safeParse(listBody);
|
||||
if (!parsedListResponse.success) {
|
||||
console.error(`Failed to parse zoekt response. Error: ${parsedListResponse.error}`);
|
||||
return unexpectedError(`Something went wrong while parsing the response from zoekt`);
|
||||
}
|
||||
|
||||
return parsedListResponse.data;
|
||||
}
|
||||
|
|
@ -1,31 +1,15 @@
|
|||
import { z } from "zod";
|
||||
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, repositoryQuerySchema, searchRequestSchema, searchResponseSchema, symbolSchema, getVersionResponseSchema } from "./schemas";
|
||||
import { getVersionResponseSchema, repositoryQuerySchema } from "./schemas";
|
||||
import { tenancyModeSchema } from "@/env.mjs";
|
||||
|
||||
export type KeymapType = "default" | "vim";
|
||||
|
||||
export type SearchRequest = z.infer<typeof searchRequestSchema>;
|
||||
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 RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
|
||||
export type Symbol = z.infer<typeof symbolSchema>;
|
||||
|
||||
export type GetVersionResponse = z.infer<typeof getVersionResponseSchema>;
|
||||
|
||||
export enum SearchQueryParams {
|
||||
query = "query",
|
||||
maxMatchDisplayCount = "maxMatchDisplayCount",
|
||||
matches = "matches",
|
||||
}
|
||||
|
||||
export type TenancyMode = z.infer<typeof tenancyModeSchema>;
|
||||
export type TenancyMode = z.infer<typeof tenancyModeSchema>;
|
||||
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
|
||||
|
|
@ -6,7 +6,8 @@ import giteaLogo from "@/public/gitea.svg";
|
|||
import gerritLogo from "@/public/gerrit.svg";
|
||||
import bitbucketLogo from "@/public/bitbucket.svg";
|
||||
import { ServiceError } from "./serviceError";
|
||||
import { Repository, RepositoryQuery } from "./types";
|
||||
import { RepositoryQuery } from "./types";
|
||||
import { Repository } from "@/features/search/types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
|
|
@ -48,15 +49,15 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined
|
|||
return undefined;
|
||||
}
|
||||
|
||||
if (!repo.RawConfig) {
|
||||
if (!repo.rawConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// @todo : use zod to validate config schema
|
||||
const webUrlType = repo.RawConfig['web-url-type']!;
|
||||
const displayName = repo.RawConfig['display-name'] ?? repo.RawConfig['name']!;
|
||||
const webUrlType = repo.rawConfig['web-url-type']!;
|
||||
const displayName = repo.rawConfig['display-name'] ?? repo.rawConfig['name']!;
|
||||
|
||||
return _getCodeHostInfoInternal(webUrlType, displayName, repo.URL);
|
||||
return _getCodeHostInfoInternal(webUrlType, displayName, repo.url);
|
||||
}
|
||||
|
||||
export const getRepoQueryCodeHostInfo = (repo: RepositoryQuery): CodeHostInfo | undefined => {
|
||||
|
|
|
|||
|
|
@ -5343,6 +5343,7 @@ __metadata:
|
|||
codemirror-lang-sparql: "npm:^2.0.0"
|
||||
codemirror-lang-spreadsheet: "npm:^1.3.0"
|
||||
codemirror-lang-zig: "npm:^0.1.0"
|
||||
cross-env: "npm:^7.0.3"
|
||||
embla-carousel-auto-scroll: "npm:^8.3.0"
|
||||
embla-carousel-react: "npm:^8.3.0"
|
||||
escape-string-regexp: "npm:^5.0.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue