mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +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",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
|
||||||
"next/core-web-vitals"
|
"next/core-web-vitals"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "cross-env SKIP_ENV_VALIDATION=1 next lint",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"dev:emails": "email dev --dir ./src/emails",
|
"dev:emails": "email dev --dir ./src/emails",
|
||||||
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
|
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
|
||||||
|
|
@ -146,6 +146,7 @@
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
"@typescript-eslint/parser": "^8.3.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.6",
|
"eslint-config-next": "14.2.6",
|
||||||
"eslint-plugin-react": "^7.35.0",
|
"eslint-plugin-react": "^7.35.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
||||||
import { TopBar } from "@/app/[domain]/components/topBar";
|
import { TopBar } from "@/app/[domain]/components/topBar";
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 { base64Decode, isServiceError } from "@/lib/utils";
|
||||||
import { CodePreview } from "./codePreview";
|
import { CodePreview } from "./codePreview";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
|
|
@ -57,7 +58,7 @@ export default async function BrowsePage({
|
||||||
if (isServiceError(reposResponse)) {
|
if (isServiceError(reposResponse)) {
|
||||||
throw new ServiceErrorException(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') {
|
if (pathType === 'tree') {
|
||||||
// @todo : proper tree handling
|
// @todo : proper tree handling
|
||||||
|
|
@ -81,7 +82,7 @@ export default async function BrowsePage({
|
||||||
<div className="bg-accent py-1 px-2 flex flex-row">
|
<div className="bg-accent py-1 px-2 flex flex-row">
|
||||||
<FileHeader
|
<FileHeader
|
||||||
fileName={path}
|
fileName={path}
|
||||||
repo={repo.Repository}
|
repo={repo}
|
||||||
branchDisplayName={revisionName}
|
branchDisplayName={revisionName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Repository } from "@/lib/types";
|
import { Repository } from "@/features/search/types";
|
||||||
import { getRepoCodeHostInfo } from "@/lib/utils";
|
import { getRepoCodeHostInfo } from "@/lib/utils";
|
||||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,7 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
searchHistorySuggestions,
|
searchHistorySuggestions,
|
||||||
languageSuggestions,
|
languageSuggestions,
|
||||||
searchContextSuggestions,
|
searchContextSuggestions,
|
||||||
|
refineModeSuggestions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// When the list of suggestions change, reset the highlight index
|
// When the list of suggestions change, reset the highlight index
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export const useSuggestionModeAndQuery = ({
|
||||||
suggestionQuery: part,
|
suggestionQuery: part,
|
||||||
suggestionMode: "refine",
|
suggestionMode: "refine",
|
||||||
}
|
}
|
||||||
}, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled]);
|
}, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled, suggestionModeMappings]);
|
||||||
|
|
||||||
// Debug logging for tracking mode transitions.
|
// Debug logging for tracking mode transitions.
|
||||||
const [prevSuggestionMode, setPrevSuggestionMode] = useState<SuggestionMode>("none");
|
const [prevSuggestionMode, setPrevSuggestionMode] = useState<SuggestionMode>("none");
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Suggestion, SuggestionMode } from "./searchSuggestionsBox";
|
||||||
import { getRepos, search } from "@/app/api/(client)/client";
|
import { getRepos, search } from "@/app/api/(client)/client";
|
||||||
import { getSearchContexts } from "@/actions";
|
import { getSearchContexts } from "@/actions";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Symbol } from "@/lib/types";
|
import { SearchSymbol } from "@/features/search/types";
|
||||||
import { languageMetadataMap } from "@/lib/languageMetadata";
|
import { languageMetadataMap } from "@/lib/languageMetadata";
|
||||||
import {
|
import {
|
||||||
VscSymbolClass,
|
VscSymbolClass,
|
||||||
|
|
@ -40,10 +40,9 @@ export const useSuggestionsData = ({
|
||||||
queryKey: ["repoSuggestions"],
|
queryKey: ["repoSuggestions"],
|
||||||
queryFn: () => getRepos(domain),
|
queryFn: () => getRepos(domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
return data.List.Repos
|
return data.repos
|
||||||
.map(r => r.Repository)
|
|
||||||
.map(r => ({
|
.map(r => ({
|
||||||
value: r.Name
|
value: r.name,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
enabled: suggestionMode === "repo",
|
enabled: suggestionMode === "repo",
|
||||||
|
|
@ -54,16 +53,17 @@ export const useSuggestionsData = ({
|
||||||
queryKey: ["fileSuggestions", suggestionQuery],
|
queryKey: ["fileSuggestions", suggestionQuery],
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: `file:${suggestionQuery}`,
|
query: `file:${suggestionQuery}`,
|
||||||
maxMatchDisplayCount: 15,
|
matches: 15,
|
||||||
|
contextLines: 1,
|
||||||
}, domain),
|
}, domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
if (isServiceError(data)) {
|
if (isServiceError(data)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.Result.Files?.map((file) => ({
|
return data.files.map((file) => ({
|
||||||
value: file.FileName
|
value: file.fileName.text,
|
||||||
})) ?? [];
|
}));
|
||||||
},
|
},
|
||||||
enabled: suggestionMode === "file"
|
enabled: suggestionMode === "file"
|
||||||
});
|
});
|
||||||
|
|
@ -73,22 +73,23 @@ export const useSuggestionsData = ({
|
||||||
queryKey: ["symbolSuggestions", suggestionQuery],
|
queryKey: ["symbolSuggestions", suggestionQuery],
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
||||||
maxMatchDisplayCount: 15,
|
matches: 15,
|
||||||
|
contextLines: 1,
|
||||||
}, domain),
|
}, domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
if (isServiceError(data)) {
|
if (isServiceError(data)) {
|
||||||
return [];
|
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) {
|
if (!symbols) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// De-duplicate on symbol name & kind.
|
// 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) => ({
|
const suggestions = Array.from(symbolMap.values()).map((symbol) => ({
|
||||||
value: symbol.Sym,
|
value: symbol.symbol,
|
||||||
Icon: getSymbolIcon(symbol),
|
Icon: getSymbolIcon(symbol),
|
||||||
} satisfies Suggestion));
|
} satisfies Suggestion));
|
||||||
|
|
||||||
|
|
@ -157,8 +158,8 @@ export const useSuggestionsData = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSymbolIcon = (symbol: Symbol) => {
|
const getSymbolIcon = (symbol: SearchSymbol) => {
|
||||||
switch (symbol.Kind) {
|
switch (symbol.kind) {
|
||||||
case "methodSpec":
|
case "methodSpec":
|
||||||
case "method":
|
case "method":
|
||||||
case "function":
|
case "function":
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import { isServiceError } from "@/lib/utils"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { OrgRole } from "@sourcebot/db"
|
import { OrgRole } from "@sourcebot/db"
|
||||||
import { CodeHostType } from "@/lib/utils"
|
import { CodeHostType } from "@/lib/utils"
|
||||||
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"
|
|
||||||
|
|
||||||
interface ConnectionManagementPageProps {
|
interface ConnectionManagementPageProps {
|
||||||
params: {
|
params: {
|
||||||
|
|
|
||||||
|
|
@ -403,7 +403,7 @@ export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[
|
||||||
selectionText: "username",
|
selectionText: "username",
|
||||||
description: (
|
description: (
|
||||||
<div className="flex flex-col">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu";
|
import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { SearchResultChunk } from "@/features/search/types";
|
||||||
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
||||||
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
||||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
||||||
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
||||||
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
||||||
import { SearchResultFileMatch } from "@/lib/types";
|
|
||||||
import { search } from "@codemirror/search";
|
import { search } from "@codemirror/search";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
||||||
|
|
@ -22,7 +22,7 @@ export interface CodePreviewFile {
|
||||||
content: string;
|
content: string;
|
||||||
filepath: string;
|
filepath: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
matches: SearchResultFileMatch[];
|
matches: SearchResultChunk[];
|
||||||
language: string;
|
language: string;
|
||||||
revision: string;
|
revision: string;
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,7 @@ export const CodePreview = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.matches.flatMap((match) => {
|
return file.matches.flatMap((match) => {
|
||||||
return match.Ranges;
|
return match.matchRanges;
|
||||||
})
|
})
|
||||||
}, [file]);
|
}, [file]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ import { fetchFileSource } from "@/app/api/(client)/client";
|
||||||
import { base64Decode } from "@/lib/utils";
|
import { base64Decode } from "@/lib/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CodePreview, CodePreviewFile } from "./codePreview";
|
import { CodePreview, CodePreviewFile } from "./codePreview";
|
||||||
import { SearchResultFile } from "@/lib/types";
|
import { SearchResultFile } from "@/features/search/types";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
interface CodePreviewPanelProps {
|
interface CodePreviewPanelProps {
|
||||||
fileMatch?: SearchResultFile;
|
fileMatch?: SearchResultFile;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -25,7 +26,7 @@ export const CodePreviewPanel = ({
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
||||||
const { data: file, isLoading } = useQuery({
|
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> => {
|
queryFn: async (): Promise<CodePreviewFile | undefined> => {
|
||||||
if (!fileMatch) {
|
if (!fileMatch) {
|
||||||
return undefined;
|
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
|
// 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.
|
// 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({
|
return fetchFileSource({
|
||||||
fileName: fileMatch.FileName,
|
fileName: fileMatch.fileName.text,
|
||||||
repository: fileMatch.Repository,
|
repository: fileMatch.repository,
|
||||||
branch,
|
branch,
|
||||||
}, domain)
|
}, domain)
|
||||||
.then(({ source }) => {
|
.then(({ source }) => {
|
||||||
const link = (() => {
|
const link = (() => {
|
||||||
const template = repoUrlTemplates[fileMatch.Repository];
|
const template = repoUrlTemplates[fileMatch.repository];
|
||||||
|
|
||||||
// This is a hacky parser for templates generated by
|
// This is a hacky parser for templates generated by
|
||||||
// the go text/template package. Example template:
|
// the go text/template package. Example template:
|
||||||
|
|
@ -55,7 +56,7 @@ export const CodePreviewPanel = ({
|
||||||
const url =
|
const url =
|
||||||
template.substring("{{URLJoinPath ".length,template.indexOf("}}"))
|
template.substring("{{URLJoinPath ".length,template.indexOf("}}"))
|
||||||
.replace(".Version", branch ?? "HEAD")
|
.replace(".Version", branch ?? "HEAD")
|
||||||
.replace(".Path", fileMatch.FileName)
|
.replace(".Path", fileMatch.fileName.text)
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((part) => {
|
.map((part) => {
|
||||||
// remove wrapping quotes
|
// remove wrapping quotes
|
||||||
|
|
@ -68,24 +69,19 @@ export const CodePreviewPanel = ({
|
||||||
const optionalQueryParams =
|
const optionalQueryParams =
|
||||||
template.substring(template.indexOf("}}") + 2)
|
template.substring(template.indexOf("}}") + 2)
|
||||||
.replace("{{.Version}}", branch ?? "HEAD")
|
.replace("{{.Version}}", branch ?? "HEAD")
|
||||||
.replace("{{.Path}}", fileMatch.FileName);
|
.replace("{{.Path}}", fileMatch.fileName.text);
|
||||||
|
|
||||||
return url + optionalQueryParams;
|
return url + optionalQueryParams;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const decodedSource = base64Decode(source);
|
const decodedSource = base64Decode(source);
|
||||||
|
|
||||||
// Filter out filename matches
|
|
||||||
const filteredMatches = fileMatch.ChunkMatches.filter((match) => {
|
|
||||||
return !match.FileName;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: decodedSource,
|
content: decodedSource,
|
||||||
filepath: fileMatch.FileName,
|
filepath: fileMatch.fileName.text,
|
||||||
matches: filteredMatches,
|
matches: fileMatch.chunks,
|
||||||
link: link,
|
link: link,
|
||||||
language: fileMatch.Language,
|
language: fileMatch.language,
|
||||||
revision: branch ?? "HEAD",
|
revision: branch ?? "HEAD",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -103,7 +99,7 @@ export const CodePreviewPanel = ({
|
||||||
return (
|
return (
|
||||||
<CodePreview
|
<CodePreview
|
||||||
file={file}
|
file={file}
|
||||||
repoName={fileMatch?.Repository}
|
repoName={fileMatch?.repository}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
selectedMatchIndex={selectedMatchIndex}
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { compareEntries, Entry } from "./entry";
|
import { compareEntries, Entry } from "./entry";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FileIcon } from "@/components/ui/fileIcon";
|
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 { cn, getRepoCodeHostInfo } from "@/lib/utils";
|
||||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Entry } from "./entry";
|
import { Entry } from "./entry";
|
||||||
import { Filter } from "./filter";
|
import { Filter } from "./filter";
|
||||||
|
|
||||||
|
|
@ -28,15 +28,15 @@ export const FilterPanel = ({
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// Helper to parse query params into sets
|
// Helper to parse query params into sets
|
||||||
const getSelectedFromQuery = (param: string) => {
|
const getSelectedFromQuery = useCallback((param: string) => {
|
||||||
const value = searchParams.get(param);
|
const value = searchParams.get(param);
|
||||||
return value ? new Set(value.split(',')) : new Set();
|
return value ? new Set(value.split(',')) : new Set();
|
||||||
};
|
}, [searchParams]);
|
||||||
|
|
||||||
const repos = useMemo(() => {
|
const repos = useMemo(() => {
|
||||||
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
|
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
|
||||||
return aggregateMatches(
|
return aggregateMatches(
|
||||||
"Repository",
|
"repository",
|
||||||
matches,
|
matches,
|
||||||
(key) => {
|
(key) => {
|
||||||
const repo: Repository | undefined = repoMetadata[key];
|
const repo: Repository | undefined = repoMetadata[key];
|
||||||
|
|
@ -60,12 +60,12 @@ export const FilterPanel = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}, [searchParams]);
|
}, [getSelectedFromQuery, matches, repoMetadata]);
|
||||||
|
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
||||||
return aggregateMatches(
|
return aggregateMatches(
|
||||||
"Language",
|
"language",
|
||||||
matches,
|
matches,
|
||||||
(key) => {
|
(key) => {
|
||||||
const Icon = (
|
const Icon = (
|
||||||
|
|
@ -81,7 +81,7 @@ export const FilterPanel = ({
|
||||||
} satisfies Entry;
|
} satisfies Entry;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [searchParams]);
|
}, [getSelectedFromQuery, matches]);
|
||||||
|
|
||||||
// Calls `onFilterChanged` with the filtered list of matches
|
// Calls `onFilterChanged` with the filtered list of matches
|
||||||
// whenever the filter state changes.
|
// whenever the filter state changes.
|
||||||
|
|
@ -91,8 +91,8 @@ export const FilterPanel = ({
|
||||||
|
|
||||||
const filteredMatches = matches.filter((match) =>
|
const filteredMatches = matches.filter((match) =>
|
||||||
(
|
(
|
||||||
(selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) &&
|
(selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) &&
|
||||||
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language))
|
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
onFilterChanged(filteredMatches);
|
onFilterChanged(filteredMatches);
|
||||||
|
|
@ -166,7 +166,7 @@ export const FilterPanel = ({
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
const aggregateMatches = (
|
const aggregateMatches = (
|
||||||
propName: 'Repository' | 'Language',
|
propName: 'repository' | 'language',
|
||||||
matches: SearchResultFile[],
|
matches: SearchResultFile[],
|
||||||
createEntry: (key: string) => Entry
|
createEntry: (key: string) => Entry
|
||||||
) => {
|
) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage";
|
import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage";
|
||||||
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
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 { EditorState, StateField, Transaction } from "@codemirror/state";
|
||||||
import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view";
|
import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view";
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
@ -43,11 +43,11 @@ export const CodePreview = ({
|
||||||
|
|
||||||
const decorations = ranges
|
const decorations = ranges
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return a.Start.ByteOffset - b.Start.ByteOffset;
|
return a.start.byteOffset - b.start.byteOffset;
|
||||||
})
|
})
|
||||||
.filter(({ Start, End }) => {
|
.filter(({ start, end }) => {
|
||||||
const startLine = Start.LineNumber - lineOffset;
|
const startLine = start.lineNumber - lineOffset;
|
||||||
const endLine = End.LineNumber - lineOffset;
|
const endLine = end.lineNumber - lineOffset;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
startLine < 1 ||
|
startLine < 1 ||
|
||||||
|
|
@ -59,12 +59,12 @@ export const CodePreview = ({
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map(({ Start, End }) => {
|
.map(({ start, end }) => {
|
||||||
const startLine = Start.LineNumber - lineOffset;
|
const startLine = start.lineNumber - lineOffset;
|
||||||
const endLine = End.LineNumber - lineOffset;
|
const endLine = end.lineNumber - lineOffset;
|
||||||
|
|
||||||
const from = document.line(startLine).from + Start.Column - 1;
|
const from = document.line(startLine).from + start.column - 1;
|
||||||
const to = document.line(endLine).from + End.Column - 1;
|
const to = document.line(endLine).from + end.column - 1;
|
||||||
return markDecoration.range(from, to);
|
return markDecoration.range(from, to);
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.from - b.from);
|
.sort((a, b) => a.from - b.from);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CodePreview } from "./codePreview";
|
import { CodePreview } from "./codePreview";
|
||||||
import { SearchResultFile, SearchResultFileMatch } from "@/lib/types";
|
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
||||||
import { base64Decode } from "@/lib/utils";
|
import { base64Decode } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
interface FileMatchProps {
|
interface FileMatchProps {
|
||||||
match: SearchResultFileMatch;
|
match: SearchResultChunk;
|
||||||
file: SearchResultFile;
|
file: SearchResultFile;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -18,11 +18,11 @@ export const FileMatch = ({
|
||||||
onOpen,
|
onOpen,
|
||||||
}: FileMatchProps) => {
|
}: FileMatchProps) => {
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
return base64Decode(match.Content);
|
return base64Decode(match.content);
|
||||||
}, [match.Content]);
|
}, [match.content]);
|
||||||
|
|
||||||
// If it's just the title, don't show a code preview
|
// If it's just the title, don't show a code preview
|
||||||
if (match.FileName) {
|
if (match.matchRanges.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,9 +40,9 @@ export const FileMatch = ({
|
||||||
>
|
>
|
||||||
<CodePreview
|
<CodePreview
|
||||||
content={content}
|
content={content}
|
||||||
language={file.Language}
|
language={file.language}
|
||||||
ranges={match.Ranges}
|
ranges={match.matchRanges}
|
||||||
lineOffset={match.ContentStart.LineNumber - 1}
|
lineOffset={match.contentStart.lineNumber - 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Repository, SearchResultFile } from "@/lib/types";
|
|
||||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { FileMatch } from "./fileMatch";
|
import { FileMatch } from "./fileMatch";
|
||||||
|
import { Repository, SearchResultFile } from "@/features/search/types";
|
||||||
|
|
||||||
export const MAX_MATCHES_TO_PREVIEW = 3;
|
export const MAX_MATCHES_TO_PREVIEW = 3;
|
||||||
|
|
||||||
|
|
@ -32,12 +32,12 @@ export const FileMatchContainer = ({
|
||||||
}: FileMatchContainerProps) => {
|
}: FileMatchContainerProps) => {
|
||||||
|
|
||||||
const matchCount = useMemo(() => {
|
const matchCount = useMemo(() => {
|
||||||
return file.ChunkMatches.length;
|
return file.chunks.length;
|
||||||
}, [file]);
|
}, [file]);
|
||||||
|
|
||||||
const matches = useMemo(() => {
|
const matches = useMemo(() => {
|
||||||
const sortedMatches = file.ChunkMatches.sort((a, b) => {
|
const sortedMatches = file.chunks.sort((a, b) => {
|
||||||
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
|
return a.contentStart.lineNumber - b.contentStart.lineNumber;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!showAllMatches) {
|
if (!showAllMatches) {
|
||||||
|
|
@ -48,18 +48,16 @@ export const FileMatchContainer = ({
|
||||||
}, [file, showAllMatches]);
|
}, [file, showAllMatches]);
|
||||||
|
|
||||||
const fileNameRange = useMemo(() => {
|
const fileNameRange = useMemo(() => {
|
||||||
for (const match of matches) {
|
if (file.fileName.matchRanges.length > 0) {
|
||||||
if (match.FileName && match.Ranges.length > 0) {
|
const range = file.fileName.matchRanges[0];
|
||||||
const range = match.Ranges[0];
|
return {
|
||||||
return {
|
from: range.start.column - 1,
|
||||||
from: range.Start.Column - 1,
|
to: range.end.column - 1,
|
||||||
to: range.End.Column - 1,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [matches]);
|
}, [file.fileName.matchRanges]);
|
||||||
|
|
||||||
const isMoreContentButtonVisible = useMemo(() => {
|
const isMoreContentButtonVisible = useMemo(() => {
|
||||||
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
||||||
|
|
@ -67,19 +65,19 @@ export const FileMatchContainer = ({
|
||||||
|
|
||||||
const onOpenMatch = useCallback((index: number) => {
|
const onOpenMatch = useCallback((index: number) => {
|
||||||
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
||||||
return acc + match.Ranges.length;
|
return acc + match.matchRanges.length;
|
||||||
}, 0);
|
}, 0);
|
||||||
onOpenFile();
|
onOpenFile();
|
||||||
onMatchIndexChanged(matchIndex);
|
onMatchIndexChanged(matchIndex);
|
||||||
}, [matches, onMatchIndexChanged, onOpenFile]);
|
}, [matches, onMatchIndexChanged, onOpenFile]);
|
||||||
|
|
||||||
const branches = useMemo(() => {
|
const branches = useMemo(() => {
|
||||||
if (!file.Branches) {
|
if (!file.branches) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.Branches;
|
return file.branches;
|
||||||
}, [file.Branches]);
|
}, [file.branches]);
|
||||||
|
|
||||||
const branchDisplayName = useMemo(() => {
|
const branchDisplayName = useMemo(() => {
|
||||||
if (!isBranchFilteringEnabled || branches.length === 0) {
|
if (!isBranchFilteringEnabled || branches.length === 0) {
|
||||||
|
|
@ -103,8 +101,8 @@ export const FileMatchContainer = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileHeader
|
<FileHeader
|
||||||
repo={repoMetadata[file.Repository]}
|
repo={repoMetadata[file.repository]}
|
||||||
fileName={file.FileName}
|
fileName={file.fileName.text}
|
||||||
fileNameHighlightRange={fileNameRange}
|
fileNameHighlightRange={fileNameRange}
|
||||||
branchDisplayName={branchDisplayName}
|
branchDisplayName={branchDisplayName}
|
||||||
branchDisplayTitle={branches.join(", ")}
|
branchDisplayTitle={branches.join(", ")}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Repository, SearchResultFile } from "@/lib/types";
|
import { Repository, SearchResultFile } from "@/features/search/types";
|
||||||
import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer";
|
import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
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
|
// Quick guesstimation ;) This needs to be quick since the virtualizer will
|
||||||
// run this upfront for all items in the list.
|
// run this upfront for all items in the list.
|
||||||
const numCodeCells = fileMatch.ChunkMatches
|
const numCodeCells = fileMatch.chunks
|
||||||
.filter(match => !match.FileName)
|
.slice(0, showAllMatches ? fileMatch.chunks.length : MAX_MATCHES_TO_PREVIEW)
|
||||||
.slice(0, showAllMatches ? fileMatch.ChunkMatches.length : MAX_MATCHES_TO_PREVIEW)
|
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
const estimatedSize =
|
const estimatedSize =
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
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 { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||||
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
@ -23,8 +23,9 @@ import { FilterPanel } from "./components/filterPanel";
|
||||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
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() {
|
export default function SearchPage() {
|
||||||
// We need a suspense boundary here since we are accessing query params
|
// We need a suspense boundary here since we are accessing query params
|
||||||
|
|
@ -40,18 +41,20 @@ export default function SearchPage() {
|
||||||
const SearchPageInternal = () => {
|
const SearchPageInternal = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||||
const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`);
|
const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`);
|
||||||
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches;
|
||||||
const { setSearchHistory } = useSearchHistory();
|
const { setSearchHistory } = useSearchHistory();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
|
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
|
||||||
queryKey: ["search", searchQuery, maxMatchDisplayCount],
|
queryKey: ["search", searchQuery, matches],
|
||||||
queryFn: () => measure(() => unwrapServiceError(search({
|
queryFn: () => measure(() => unwrapServiceError(search({
|
||||||
query: searchQuery,
|
query: searchQuery,
|
||||||
maxMatchDisplayCount,
|
matches,
|
||||||
|
contextLines: 3,
|
||||||
|
whole: false,
|
||||||
}, domain)), "client.search"),
|
}, domain)), "client.search"),
|
||||||
select: ({ data, durationMs }) => ({
|
select: ({ data, durationMs }) => ({
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -95,12 +98,11 @@ const SearchPageInternal = () => {
|
||||||
queryKey: ["repos"],
|
queryKey: ["repos"],
|
||||||
queryFn: () => getRepos(domain),
|
queryFn: () => getRepos(domain),
|
||||||
select: (data): Record<string, Repository> =>
|
select: (data): Record<string, Repository> =>
|
||||||
data.List.Repos
|
data.repos
|
||||||
.map(r => r.Repository)
|
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc, repo) => ({
|
(acc, repo) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[repo.Name]: repo,
|
[repo.name]: repo,
|
||||||
}),
|
}),
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
|
|
@ -112,29 +114,29 @@ const SearchPageInternal = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileLanguages = searchResponse.Result.Files?.map(file => file.Language) || [];
|
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
||||||
|
|
||||||
captureEvent("search_finished", {
|
captureEvent("search_finished", {
|
||||||
contentBytesLoaded: searchResponse.Result.ContentBytesLoaded,
|
durationMs: searchResponse.durationMs,
|
||||||
indexBytesLoaded: searchResponse.Result.IndexBytesLoaded,
|
fileCount: searchResponse.zoektStats.fileCount,
|
||||||
crashes: searchResponse.Result.Crashes,
|
matchCount: searchResponse.zoektStats.matchCount,
|
||||||
durationMs: searchResponse.Result.Duration / 1000000,
|
filesSkipped: searchResponse.zoektStats.filesSkipped,
|
||||||
fileCount: searchResponse.Result.FileCount,
|
contentBytesLoaded: searchResponse.zoektStats.contentBytesLoaded,
|
||||||
shardFilesConsidered: searchResponse.Result.ShardFilesConsidered,
|
indexBytesLoaded: searchResponse.zoektStats.indexBytesLoaded,
|
||||||
filesConsidered: searchResponse.Result.FilesConsidered,
|
crashes: searchResponse.zoektStats.crashes,
|
||||||
filesLoaded: searchResponse.Result.FilesLoaded,
|
shardFilesConsidered: searchResponse.zoektStats.shardFilesConsidered,
|
||||||
filesSkipped: searchResponse.Result.FilesSkipped,
|
filesConsidered: searchResponse.zoektStats.filesConsidered,
|
||||||
shardsScanned: searchResponse.Result.ShardsScanned,
|
filesLoaded: searchResponse.zoektStats.filesLoaded,
|
||||||
shardsSkipped: searchResponse.Result.ShardsSkipped,
|
shardsScanned: searchResponse.zoektStats.shardsScanned,
|
||||||
shardsSkippedFilter: searchResponse.Result.ShardsSkippedFilter,
|
shardsSkipped: searchResponse.zoektStats.shardsSkipped,
|
||||||
matchCount: searchResponse.Result.MatchCount,
|
shardsSkippedFilter: searchResponse.zoektStats.shardsSkippedFilter,
|
||||||
ngramMatches: searchResponse.Result.NgramMatches,
|
ngramMatches: searchResponse.zoektStats.ngramMatches,
|
||||||
ngramLookups: searchResponse.Result.NgramLookups,
|
ngramLookups: searchResponse.zoektStats.ngramLookups,
|
||||||
wait: searchResponse.Result.Wait,
|
wait: searchResponse.zoektStats.wait,
|
||||||
matchTreeConstruction: searchResponse.Result.MatchTreeConstruction,
|
matchTreeConstruction: searchResponse.zoektStats.matchTreeConstruction,
|
||||||
matchTreeSearch: searchResponse.Result.MatchTreeSearch,
|
matchTreeSearch: searchResponse.zoektStats.matchTreeSearch,
|
||||||
regexpsConsidered: searchResponse.Result.RegexpsConsidered,
|
regexpsConsidered: searchResponse.zoektStats.regexpsConsidered,
|
||||||
flushReason: searchResponse.Result.FlushReason,
|
flushReason: searchResponse.zoektStats.flushReason,
|
||||||
fileLanguages,
|
fileLanguages,
|
||||||
});
|
});
|
||||||
}, [captureEvent, searchQuery, searchResponse]);
|
}, [captureEvent, searchQuery, searchResponse]);
|
||||||
|
|
@ -151,24 +153,24 @@ const SearchPageInternal = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileMatches: searchResponse.Result.Files ?? [],
|
fileMatches: searchResponse.files ?? [],
|
||||||
searchDurationMs: Math.round(searchResponse.durationMs),
|
searchDurationMs: Math.round(searchResponse.durationMs),
|
||||||
totalMatchCount: searchResponse.Result.MatchCount,
|
totalMatchCount: searchResponse.zoektStats.matchCount,
|
||||||
isBranchFilteringEnabled: searchResponse.isBranchFilteringEnabled,
|
isBranchFilteringEnabled: searchResponse.isBranchFilteringEnabled,
|
||||||
repoUrlTemplates: searchResponse.Result.RepoURLs,
|
repoUrlTemplates: searchResponse.repoUrlTemplates,
|
||||||
}
|
}
|
||||||
}, [searchResponse]);
|
}, [searchResponse]);
|
||||||
|
|
||||||
const isMoreResultsButtonVisible = useMemo(() => {
|
const isMoreResultsButtonVisible = useMemo(() => {
|
||||||
return totalMatchCount > maxMatchDisplayCount;
|
return totalMatchCount > matches;
|
||||||
}, [totalMatchCount, maxMatchDisplayCount]);
|
}, [totalMatchCount, matches]);
|
||||||
|
|
||||||
const numMatches = useMemo(() => {
|
const numMatches = useMemo(() => {
|
||||||
// Accumualtes the number of matches across all files
|
// Accumualtes the number of matches across all files
|
||||||
return fileMatches.reduce(
|
return fileMatches.reduce(
|
||||||
(acc, file) =>
|
(acc, file) =>
|
||||||
acc + file.ChunkMatches.reduce(
|
acc + file.chunks.reduce(
|
||||||
(acc, chunk) => acc + chunk.Ranges.length,
|
(acc, chunk) => acc + chunk.matchRanges.length,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
|
|
@ -178,10 +180,10 @@ const SearchPageInternal = () => {
|
||||||
const onLoadMoreResults = useCallback(() => {
|
const onLoadMoreResults = useCallback(() => {
|
||||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||||
[SearchQueryParams.query, searchQuery],
|
[SearchQueryParams.query, searchQuery],
|
||||||
[SearchQueryParams.maxMatchDisplayCount, `${maxMatchDisplayCount * 2}`],
|
[SearchQueryParams.matches, `${matches * 2}`],
|
||||||
)
|
)
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}, [maxMatchDisplayCount, router, searchQuery, domain]);
|
}, [matches, router, searchQuery, domain]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-clip">
|
<div className="flex flex-col h-screen overflow-clip">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas";
|
import { getVersionResponseSchema } from "@/lib/schemas";
|
||||||
import { ServiceError } from "@/lib/serviceError";
|
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 { 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> => {
|
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse | ServiceError> => {
|
||||||
const result = await fetch("/api/search", {
|
const result = await fetch("/api/search", {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { listRepositories } from "@/lib/server/searchService";
|
import { listRepositories } from "@/features/search/listReposApi";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { search } from "@/lib/server/searchService";
|
import { search } from "@/features/search/searchApi";
|
||||||
import { searchRequestSchema } from "@/lib/schemas";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
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) => {
|
export const POST = async (request: NextRequest) => {
|
||||||
const domain = request.headers.get("X-Org-Domain")!;
|
const domain = request.headers.get("X-Org-Domain")!;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fileSourceRequestSchema } from "@/lib/schemas";
|
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||||
import { getFileSource } from "@/lib/server/searchService";
|
|
||||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
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) => {
|
export const POST = async (request: NextRequest) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const planLabels = {
|
const planLabels = {
|
||||||
oss: "OSS",
|
oss: "OSS",
|
||||||
"cloud:team": "Team",
|
"cloud:team": "Team",
|
||||||
|
|
@ -7,6 +8,7 @@ const planLabels = {
|
||||||
export type Plan = keyof typeof planLabels;
|
export type Plan = keyof typeof planLabels;
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const entitlements = [
|
const entitlements = [
|
||||||
"search-contexts",
|
"search-contexts",
|
||||||
"billing"
|
"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 { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state";
|
||||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||||
import { SearchResultRange } from "../types";
|
import { SearchResultRange } from "@/features/search/types";
|
||||||
|
|
||||||
const setMatchState = StateEffect.define<{
|
const setMatchState = StateEffect.define<{
|
||||||
selectedMatchIndex: number,
|
selectedMatchIndex: number,
|
||||||
|
|
@ -8,9 +8,9 @@ const setMatchState = StateEffect.define<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => {
|
const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => {
|
||||||
const { Start, End } = range;
|
const { start, end } = range;
|
||||||
const from = document.line(Start.LineNumber).from + Start.Column - 1;
|
const from = document.line(start.lineNumber).from + start.column - 1;
|
||||||
const to = document.line(End.LineNumber).from + End.Column - 1;
|
const to = document.line(end.lineNumber).from + end.column - 1;
|
||||||
return { from, to };
|
return { from, to };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ const matchHighlighter = StateField.define<DecorationSet>({
|
||||||
|
|
||||||
const decorations = ranges
|
const decorations = ranges
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return a.Start.ByteOffset - b.Start.ByteOffset;
|
return a.start.byteOffset - b.start.byteOffset;
|
||||||
})
|
})
|
||||||
.map((range, index) => {
|
.map((range, index) => {
|
||||||
const { from, to } = convertToCodeMirrorRange(range, transaction.newDoc);
|
const { from, to } = convertToCodeMirrorRange(range, transaction.newDoc);
|
||||||
|
|
|
||||||
|
|
@ -3,103 +3,6 @@ import { RepoIndexingStatus } from "@sourcebot/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isServiceError } from "./utils";
|
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({
|
export const secretCreateRequestSchema = z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
|
|
@ -109,62 +12,6 @@ export const secreteDeleteRequestSchema = z.object({
|
||||||
key: z.string(),
|
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({
|
export const repositoryQuerySchema = z.object({
|
||||||
codeHostType: z.string(),
|
codeHostType: z.string(),
|
||||||
repoId: z.number(),
|
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 { 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";
|
import { tenancyModeSchema } from "@/env.mjs";
|
||||||
|
|
||||||
export type KeymapType = "default" | "vim";
|
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 type GetVersionResponse = z.infer<typeof getVersionResponseSchema>;
|
||||||
|
|
||||||
export enum SearchQueryParams {
|
export enum SearchQueryParams {
|
||||||
query = "query",
|
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 gerritLogo from "@/public/gerrit.svg";
|
||||||
import bitbucketLogo from "@/public/bitbucket.svg";
|
import bitbucketLogo from "@/public/bitbucket.svg";
|
||||||
import { ServiceError } from "./serviceError";
|
import { ServiceError } from "./serviceError";
|
||||||
import { Repository, RepositoryQuery } from "./types";
|
import { RepositoryQuery } from "./types";
|
||||||
|
import { Repository } from "@/features/search/types";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|
@ -48,15 +49,15 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repo.RawConfig) {
|
if (!repo.rawConfig) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo : use zod to validate config schema
|
// @todo : use zod to validate config schema
|
||||||
const webUrlType = repo.RawConfig['web-url-type']!;
|
const webUrlType = repo.rawConfig['web-url-type']!;
|
||||||
const displayName = repo.RawConfig['display-name'] ?? repo.RawConfig['name']!;
|
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 => {
|
export const getRepoQueryCodeHostInfo = (repo: RepositoryQuery): CodeHostInfo | undefined => {
|
||||||
|
|
|
||||||
|
|
@ -5343,6 +5343,7 @@ __metadata:
|
||||||
codemirror-lang-sparql: "npm:^2.0.0"
|
codemirror-lang-sparql: "npm:^2.0.0"
|
||||||
codemirror-lang-spreadsheet: "npm:^1.3.0"
|
codemirror-lang-spreadsheet: "npm:^1.3.0"
|
||||||
codemirror-lang-zig: "npm:^0.1.0"
|
codemirror-lang-zig: "npm:^0.1.0"
|
||||||
|
cross-env: "npm:^7.0.3"
|
||||||
embla-carousel-auto-scroll: "npm:^8.3.0"
|
embla-carousel-auto-scroll: "npm:^8.3.0"
|
||||||
embla-carousel-react: "npm:^8.3.0"
|
embla-carousel-react: "npm:^8.3.0"
|
||||||
escape-string-regexp: "npm:^5.0.0"
|
escape-string-regexp: "npm:^5.0.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue