chore: Sourcebot REST api surface (#290)

This commit is contained in:
Brendan Kellam 2025-05-03 11:33:58 -07:00 committed by GitHub
parent 0119510ce3
commit eb10d599f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 746 additions and 550 deletions

View file

@ -7,7 +7,6 @@
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"next/core-web-vitals"
],
"rules": {

View file

@ -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",

View file

@ -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>

View file

@ -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";

View file

@ -275,6 +275,7 @@ const SearchSuggestionsBox = forwardRef(({
searchHistorySuggestions,
languageSuggestions,
searchContextSuggestions,
refineModeSuggestions,
]);
// When the list of suggestions change, reset the highlight index

View file

@ -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");

View file

@ -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":

View file

@ -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: {

View file

@ -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&apos;re using an App Password (stored in <Code>token</Code>) for authentication.</span>
</div>
)
},

View file

@ -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]);

View 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}

View file

@ -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"

View file

@ -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
) => {

View file

@ -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);

View file

@ -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>
);

View file

@ -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(", ")}

View file

@ -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 =

View file

@ -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">

View file

@ -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", {

View file

@ -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";

View file

@ -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")!;

View file

@ -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();

View file

@ -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"

View 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;
}

View 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);
}

View 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(),
});

View 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);
}

View 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>;

View 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,
})
});

View file

@ -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);

View file

@ -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(),

View file

@ -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;
}

View file

@ -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 RepositoryQuery = z.infer<typeof repositoryQuerySchema>;

View file

@ -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 => {

View file

@ -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"