From eb10d599f3bec71af8c4e1e23985ca2bb6f81a97 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 3 May 2025 11:33:58 -0700 Subject: [PATCH] chore: Sourcebot REST api surface (#290) --- packages/web/.eslintrc.json | 1 - packages/web/package.json | 3 +- .../app/[domain]/browse/[...path]/page.tsx | 7 +- .../app/[domain]/components/fileHeader.tsx | 2 +- .../searchBar/searchSuggestionsBox.tsx | 1 + .../searchBar/useSuggestionModeAndQuery.ts | 2 +- .../searchBar/useSuggestionsData.ts | 29 +-- .../app/[domain]/connections/[id]/page.tsx | 1 - .../app/[domain]/connections/quickActions.tsx | 2 +- .../codePreviewPanel/codePreview.tsx | 6 +- .../components/codePreviewPanel/index.tsx | 30 +-- .../search/components/filterPanel/filter.tsx | 1 - .../search/components/filterPanel/index.tsx | 22 +- .../searchResultsPanel/codePreview.tsx | 20 +- .../searchResultsPanel/fileMatch.tsx | 16 +- .../searchResultsPanel/fileMatchContainer.tsx | 34 ++- .../components/searchResultsPanel/index.tsx | 7 +- packages/web/src/app/[domain]/search/page.tsx | 80 +++--- packages/web/src/app/api/(client)/client.ts | 16 +- .../web/src/app/api/(server)/repos/route.ts | 2 +- .../web/src/app/api/(server)/search/route.ts | 6 +- .../web/src/app/api/(server)/source/route.ts | 6 +- .../src/features/entitlements/constants.ts | 2 + .../web/src/features/search/fileSourceApi.ts | 42 ++++ .../web/src/features/search/listReposApi.ts | 44 ++++ packages/web/src/features/search/schemas.ts | 104 ++++++++ packages/web/src/features/search/searchApi.ts | 230 ++++++++++++++++++ packages/web/src/features/search/types.ts | 25 ++ .../server => features/search}/zoektClient.ts | 0 .../web/src/features/search/zoektSchema.ts | 132 ++++++++++ .../searchResultHighlightExtension.ts | 10 +- packages/web/src/lib/schemas.ts | 153 ------------ packages/web/src/lib/server/searchService.ts | 224 ----------------- packages/web/src/lib/types.ts | 24 +- packages/web/src/lib/utils.ts | 11 +- yarn.lock | 1 + 36 files changed, 746 insertions(+), 550 deletions(-) create mode 100644 packages/web/src/features/search/fileSourceApi.ts create mode 100644 packages/web/src/features/search/listReposApi.ts create mode 100644 packages/web/src/features/search/schemas.ts create mode 100644 packages/web/src/features/search/searchApi.ts create mode 100644 packages/web/src/features/search/types.ts rename packages/web/src/{lib/server => features/search}/zoektClient.ts (100%) create mode 100644 packages/web/src/features/search/zoektSchema.ts delete mode 100644 packages/web/src/lib/server/searchService.ts diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index c381b6b5..6b1e43a1 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -7,7 +7,6 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "plugin:react-hooks/recommended", "next/core-web-vitals" ], "rules": { diff --git a/packages/web/package.json b/packages/web/package.json index 5cd5dfdb..769b0e91 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "cross-env SKIP_ENV_VALIDATION=1 next lint", "test": "vitest", "dev:emails": "email dev --dir ./src/emails", "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe" @@ -146,6 +146,7 @@ "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/parser": "^8.3.0", + "cross-env": "^7.0.3", "eslint": "^8", "eslint-config-next": "14.2.6", "eslint-plugin-react": "^7.35.0", diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 804f306b..89e4e293 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,7 +1,8 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; -import { getFileSource, listRepositories } from '@/lib/server/searchService'; +import { getFileSource } from '@/features/search/fileSourceApi'; +import { listRepositories } from '@/features/search/listReposApi'; import { base64Decode, isServiceError } from "@/lib/utils"; import { CodePreview } from "./codePreview"; import { ErrorCode } from "@/lib/errorCodes"; @@ -57,7 +58,7 @@ export default async function BrowsePage({ if (isServiceError(reposResponse)) { throw new ServiceErrorException(reposResponse); } - const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); + const repo = reposResponse.repos.find(r => r.name === repoName); if (pathType === 'tree') { // @todo : proper tree handling @@ -81,7 +82,7 @@ export default async function BrowsePage({
diff --git a/packages/web/src/app/[domain]/components/fileHeader.tsx b/packages/web/src/app/[domain]/components/fileHeader.tsx index 6b8630d6..0ff343f8 100644 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ b/packages/web/src/app/[domain]/components/fileHeader.tsx @@ -1,4 +1,4 @@ -import { Repository } from "@/lib/types"; +import { Repository } from "@/features/search/types"; import { getRepoCodeHostInfo } from "@/lib/utils"; import { LaptopIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index f95a926e..5b8fdac3 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -275,6 +275,7 @@ const SearchSuggestionsBox = forwardRef(({ searchHistorySuggestions, languageSuggestions, searchContextSuggestions, + refineModeSuggestions, ]); // When the list of suggestions change, reset the highlight index diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts index 6aa4ff9d..42474a17 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts @@ -69,7 +69,7 @@ export const useSuggestionModeAndQuery = ({ suggestionQuery: part, suggestionMode: "refine", } - }, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled]); + }, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled, suggestionModeMappings]); // Debug logging for tracking mode transitions. const [prevSuggestionMode, setPrevSuggestionMode] = useState("none"); diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index ac13d4f8..e77c07cf 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -5,7 +5,7 @@ import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; import { getRepos, search } from "@/app/api/(client)/client"; import { getSearchContexts } from "@/actions"; import { useMemo } from "react"; -import { Symbol } from "@/lib/types"; +import { SearchSymbol } from "@/features/search/types"; import { languageMetadataMap } from "@/lib/languageMetadata"; import { VscSymbolClass, @@ -40,10 +40,9 @@ export const useSuggestionsData = ({ queryKey: ["repoSuggestions"], queryFn: () => getRepos(domain), select: (data): Suggestion[] => { - return data.List.Repos - .map(r => r.Repository) + return data.repos .map(r => ({ - value: r.Name + value: r.name, })); }, enabled: suggestionMode === "repo", @@ -54,16 +53,17 @@ export const useSuggestionsData = ({ queryKey: ["fileSuggestions", suggestionQuery], queryFn: () => search({ query: `file:${suggestionQuery}`, - maxMatchDisplayCount: 15, + matches: 15, + contextLines: 1, }, domain), select: (data): Suggestion[] => { if (isServiceError(data)) { return []; } - return data.Result.Files?.map((file) => ({ - value: file.FileName - })) ?? []; + return data.files.map((file) => ({ + value: file.fileName.text, + })); }, enabled: suggestionMode === "file" }); @@ -73,22 +73,23 @@ export const useSuggestionsData = ({ queryKey: ["symbolSuggestions", suggestionQuery], queryFn: () => search({ query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`, - maxMatchDisplayCount: 15, + matches: 15, + contextLines: 1, }, domain), select: (data): Suggestion[] => { if (isServiceError(data)) { return []; } - const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []); + const symbols = data.files.flatMap((file) => file.chunks).flatMap((chunk) => chunk.symbols ?? []); if (!symbols) { return []; } // De-duplicate on symbol name & kind. - const symbolMap = new Map(symbols.map((symbol: Symbol) => [`${symbol.Kind}.${symbol.Sym}`, symbol])); + const symbolMap = new Map(symbols.map((symbol: SearchSymbol) => [`${symbol.kind}.${symbol.symbol}`, symbol])); const suggestions = Array.from(symbolMap.values()).map((symbol) => ({ - value: symbol.Sym, + value: symbol.symbol, Icon: getSymbolIcon(symbol), } satisfies Suggestion)); @@ -157,8 +158,8 @@ export const useSuggestionsData = ({ } } -const getSymbolIcon = (symbol: Symbol) => { - switch (symbol.Kind) { +const getSymbolIcon = (symbol: SearchSymbol) => { + switch (symbol.kind) { case "methodSpec": case "method": case "function": diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index 93a53e9d..04fc1512 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -22,7 +22,6 @@ import { isServiceError } from "@/lib/utils" import { notFound } from "next/navigation" import { OrgRole } from "@sourcebot/db" import { CodeHostType } from "@/lib/utils" -import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type" interface ConnectionManagementPageProps { params: { diff --git a/packages/web/src/app/[domain]/connections/quickActions.tsx b/packages/web/src/app/[domain]/connections/quickActions.tsx index d0e7736d..af0b4f05 100644 --- a/packages/web/src/app/[domain]/connections/quickActions.tsx +++ b/packages/web/src/app/[domain]/connections/quickActions.tsx @@ -403,7 +403,7 @@ export const bitbucketCloudQuickActions: QuickAction[ selectionText: "username", description: (
- Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication. + Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication.
) }, diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx index 0681d503..6658d9c7 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx @@ -3,12 +3,12 @@ import { EditorContextMenu } from "@/app/[domain]/components/editorContextMenu"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { SearchResultChunk } from "@/features/search/types"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; import { useKeymapExtension } from "@/hooks/useKeymapExtension"; import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; -import { SearchResultFileMatch } from "@/lib/types"; import { search } from "@codemirror/search"; import { EditorView } from "@codemirror/view"; import { Cross1Icon, FileIcon } from "@radix-ui/react-icons"; @@ -22,7 +22,7 @@ export interface CodePreviewFile { content: string; filepath: string; link?: string; - matches: SearchResultFileMatch[]; + matches: SearchResultChunk[]; language: string; revision: string; } @@ -84,7 +84,7 @@ export const CodePreview = ({ } return file.matches.flatMap((match) => { - return match.Ranges; + return match.matchRanges; }) }, [file]); diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx index dc55b84d..97218b3d 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -4,9 +4,10 @@ import { fetchFileSource } from "@/app/api/(client)/client"; import { base64Decode } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { CodePreview, CodePreviewFile } from "./codePreview"; -import { SearchResultFile } from "@/lib/types"; +import { SearchResultFile } from "@/features/search/types"; import { useDomain } from "@/hooks/useDomain"; import { SymbolIcon } from "@radix-ui/react-icons"; + interface CodePreviewPanelProps { fileMatch?: SearchResultFile; onClose: () => void; @@ -25,7 +26,7 @@ export const CodePreviewPanel = ({ const domain = useDomain(); const { data: file, isLoading } = useQuery({ - queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository, fileMatch?.Branches], + queryKey: ["source", fileMatch?.fileName, fileMatch?.repository, fileMatch?.branches], queryFn: async (): Promise => { if (!fileMatch) { return undefined; @@ -33,16 +34,16 @@ export const CodePreviewPanel = ({ // If there are multiple branches pointing to the same revision of this file, it doesn't // matter which branch we use here, so use the first one. - const branch = fileMatch.Branches && fileMatch.Branches.length > 0 ? fileMatch.Branches[0] : undefined; + const branch = fileMatch.branches && fileMatch.branches.length > 0 ? fileMatch.branches[0] : undefined; return fetchFileSource({ - fileName: fileMatch.FileName, - repository: fileMatch.Repository, + fileName: fileMatch.fileName.text, + repository: fileMatch.repository, branch, }, domain) .then(({ source }) => { const link = (() => { - const template = repoUrlTemplates[fileMatch.Repository]; + const template = repoUrlTemplates[fileMatch.repository]; // This is a hacky parser for templates generated by // the go text/template package. Example template: @@ -55,7 +56,7 @@ export const CodePreviewPanel = ({ const url = template.substring("{{URLJoinPath ".length,template.indexOf("}}")) .replace(".Version", branch ?? "HEAD") - .replace(".Path", fileMatch.FileName) + .replace(".Path", fileMatch.fileName.text) .split(" ") .map((part) => { // remove wrapping quotes @@ -68,24 +69,19 @@ export const CodePreviewPanel = ({ const optionalQueryParams = template.substring(template.indexOf("}}") + 2) .replace("{{.Version}}", branch ?? "HEAD") - .replace("{{.Path}}", fileMatch.FileName); + .replace("{{.Path}}", fileMatch.fileName.text); return url + optionalQueryParams; })(); const decodedSource = base64Decode(source); - // Filter out filename matches - const filteredMatches = fileMatch.ChunkMatches.filter((match) => { - return !match.FileName; - }); - return { content: decodedSource, - filepath: fileMatch.FileName, - matches: filteredMatches, + filepath: fileMatch.fileName.text, + matches: fileMatch.chunks, link: link, - language: fileMatch.Language, + language: fileMatch.language, revision: branch ?? "HEAD", }; }); @@ -103,7 +99,7 @@ export const CodePreviewPanel = ({ return ( { + const getSelectedFromQuery = useCallback((param: string) => { const value = searchParams.get(param); return value ? new Set(value.split(',')) : new Set(); - }; + }, [searchParams]); const repos = useMemo(() => { const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); return aggregateMatches( - "Repository", + "repository", matches, (key) => { const repo: Repository | undefined = repoMetadata[key]; @@ -60,12 +60,12 @@ export const FilterPanel = ({ }; } ) - }, [searchParams]); + }, [getSelectedFromQuery, matches, repoMetadata]); const languages = useMemo(() => { const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); return aggregateMatches( - "Language", + "language", matches, (key) => { const Icon = ( @@ -81,7 +81,7 @@ export const FilterPanel = ({ } satisfies Entry; } ); - }, [searchParams]); + }, [getSelectedFromQuery, matches]); // Calls `onFilterChanged` with the filtered list of matches // whenever the filter state changes. @@ -91,8 +91,8 @@ export const FilterPanel = ({ const filteredMatches = matches.filter((match) => ( - (selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) && - (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) + (selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) && + (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language)) ) ); onFilterChanged(filteredMatches); @@ -166,7 +166,7 @@ export const FilterPanel = ({ * } */ const aggregateMatches = ( - propName: 'Repository' | 'Language', + propName: 'repository' | 'language', matches: SearchResultFile[], createEntry: (key: string) => Entry ) => { diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx index d87cde69..47cb2678 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx @@ -2,7 +2,7 @@ import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension"; -import { SearchResultRange } from "@/lib/types"; +import { SearchResultRange } from "@/features/search/types"; import { EditorState, StateField, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view"; import { useMemo, useRef } from "react"; @@ -43,11 +43,11 @@ export const CodePreview = ({ const decorations = ranges .sort((a, b) => { - return a.Start.ByteOffset - b.Start.ByteOffset; + return a.start.byteOffset - b.start.byteOffset; }) - .filter(({ Start, End }) => { - const startLine = Start.LineNumber - lineOffset; - const endLine = End.LineNumber - lineOffset; + .filter(({ start, end }) => { + const startLine = start.lineNumber - lineOffset; + const endLine = end.lineNumber - lineOffset; if ( startLine < 1 || @@ -59,12 +59,12 @@ export const CodePreview = ({ } return true; }) - .map(({ Start, End }) => { - const startLine = Start.LineNumber - lineOffset; - const endLine = End.LineNumber - lineOffset; + .map(({ start, end }) => { + const startLine = start.lineNumber - lineOffset; + const endLine = end.lineNumber - lineOffset; - const from = document.line(startLine).from + Start.Column - 1; - const to = document.line(endLine).from + End.Column - 1; + const from = document.line(startLine).from + start.column - 1; + const to = document.line(endLine).from + end.column - 1; return markDecoration.range(from, to); }) .sort((a, b) => a.from - b.from); diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx index 981ad746..aaefe1a6 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx @@ -2,12 +2,12 @@ import { useMemo } from "react"; import { CodePreview } from "./codePreview"; -import { SearchResultFile, SearchResultFileMatch } from "@/lib/types"; +import { SearchResultFile, SearchResultChunk } from "@/features/search/types"; import { base64Decode } from "@/lib/utils"; interface FileMatchProps { - match: SearchResultFileMatch; + match: SearchResultChunk; file: SearchResultFile; onOpen: () => void; } @@ -18,11 +18,11 @@ export const FileMatch = ({ onOpen, }: FileMatchProps) => { const content = useMemo(() => { - return base64Decode(match.Content); - }, [match.Content]); + return base64Decode(match.content); + }, [match.content]); // If it's just the title, don't show a code preview - if (match.FileName) { + if (match.matchRanges.length === 0) { return null; } @@ -40,9 +40,9 @@ export const FileMatch = ({ > ); diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index 0a8e74a6..2efddcc7 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -2,10 +2,10 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { Separator } from "@/components/ui/separator"; -import { Repository, SearchResultFile } from "@/lib/types"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; import { useCallback, useMemo } from "react"; import { FileMatch } from "./fileMatch"; +import { Repository, SearchResultFile } from "@/features/search/types"; export const MAX_MATCHES_TO_PREVIEW = 3; @@ -32,12 +32,12 @@ export const FileMatchContainer = ({ }: FileMatchContainerProps) => { const matchCount = useMemo(() => { - return file.ChunkMatches.length; + return file.chunks.length; }, [file]); const matches = useMemo(() => { - const sortedMatches = file.ChunkMatches.sort((a, b) => { - return a.ContentStart.LineNumber - b.ContentStart.LineNumber; + const sortedMatches = file.chunks.sort((a, b) => { + return a.contentStart.lineNumber - b.contentStart.lineNumber; }); if (!showAllMatches) { @@ -48,18 +48,16 @@ export const FileMatchContainer = ({ }, [file, showAllMatches]); const fileNameRange = useMemo(() => { - for (const match of matches) { - if (match.FileName && match.Ranges.length > 0) { - const range = match.Ranges[0]; - return { - from: range.Start.Column - 1, - to: range.End.Column - 1, - } + if (file.fileName.matchRanges.length > 0) { + const range = file.fileName.matchRanges[0]; + return { + from: range.start.column - 1, + to: range.end.column - 1, } } return undefined; - }, [matches]); + }, [file.fileName.matchRanges]); const isMoreContentButtonVisible = useMemo(() => { return matchCount > MAX_MATCHES_TO_PREVIEW; @@ -67,19 +65,19 @@ export const FileMatchContainer = ({ const onOpenMatch = useCallback((index: number) => { const matchIndex = matches.slice(0, index).reduce((acc, match) => { - return acc + match.Ranges.length; + return acc + match.matchRanges.length; }, 0); onOpenFile(); onMatchIndexChanged(matchIndex); }, [matches, onMatchIndexChanged, onOpenFile]); const branches = useMemo(() => { - if (!file.Branches) { + if (!file.branches) { return []; } - return file.Branches; - }, [file.Branches]); + return file.branches; + }, [file.branches]); const branchDisplayName = useMemo(() => { if (!isBranchFilteringEnabled || branches.length === 0) { @@ -103,8 +101,8 @@ export const FileMatchContainer = ({ }} > !match.FileName) - .slice(0, showAllMatches ? fileMatch.ChunkMatches.length : MAX_MATCHES_TO_PREVIEW) + const numCodeCells = fileMatch.chunks + .slice(0, showAllMatches ? fileMatch.chunks.length : MAX_MATCHES_TO_PREVIEW) .length; const estimatedSize = diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index aab869fa..f3aa90c5 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -9,7 +9,7 @@ import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useSearchHistory } from "@/hooks/useSearchHistory"; -import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; +import { SearchQueryParams } from "@/lib/types"; import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils"; import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; @@ -23,8 +23,9 @@ import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; +import { Repository, SearchResultFile } from "@/features/search/types"; -const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; +const DEFAULT_MATCH_COUNT = 10000; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params @@ -40,18 +41,20 @@ export default function SearchPage() { const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; - const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`); - const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount; + const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`); + const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); const { toast } = useToast(); const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ - queryKey: ["search", searchQuery, maxMatchDisplayCount], + queryKey: ["search", searchQuery, matches], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, - maxMatchDisplayCount, + matches, + contextLines: 3, + whole: false, }, domain)), "client.search"), select: ({ data, durationMs }) => ({ ...data, @@ -95,12 +98,11 @@ const SearchPageInternal = () => { queryKey: ["repos"], queryFn: () => getRepos(domain), select: (data): Record => - data.List.Repos - .map(r => r.Repository) + data.repos .reduce( (acc, repo) => ({ ...acc, - [repo.Name]: repo, + [repo.name]: repo, }), {}, ), @@ -112,29 +114,29 @@ const SearchPageInternal = () => { return; } - const fileLanguages = searchResponse.Result.Files?.map(file => file.Language) || []; + const fileLanguages = searchResponse.files?.map(file => file.language) || []; captureEvent("search_finished", { - contentBytesLoaded: searchResponse.Result.ContentBytesLoaded, - indexBytesLoaded: searchResponse.Result.IndexBytesLoaded, - crashes: searchResponse.Result.Crashes, - durationMs: searchResponse.Result.Duration / 1000000, - fileCount: searchResponse.Result.FileCount, - shardFilesConsidered: searchResponse.Result.ShardFilesConsidered, - filesConsidered: searchResponse.Result.FilesConsidered, - filesLoaded: searchResponse.Result.FilesLoaded, - filesSkipped: searchResponse.Result.FilesSkipped, - shardsScanned: searchResponse.Result.ShardsScanned, - shardsSkipped: searchResponse.Result.ShardsSkipped, - shardsSkippedFilter: searchResponse.Result.ShardsSkippedFilter, - matchCount: searchResponse.Result.MatchCount, - ngramMatches: searchResponse.Result.NgramMatches, - ngramLookups: searchResponse.Result.NgramLookups, - wait: searchResponse.Result.Wait, - matchTreeConstruction: searchResponse.Result.MatchTreeConstruction, - matchTreeSearch: searchResponse.Result.MatchTreeSearch, - regexpsConsidered: searchResponse.Result.RegexpsConsidered, - flushReason: searchResponse.Result.FlushReason, + durationMs: searchResponse.durationMs, + fileCount: searchResponse.zoektStats.fileCount, + matchCount: searchResponse.zoektStats.matchCount, + filesSkipped: searchResponse.zoektStats.filesSkipped, + contentBytesLoaded: searchResponse.zoektStats.contentBytesLoaded, + indexBytesLoaded: searchResponse.zoektStats.indexBytesLoaded, + crashes: searchResponse.zoektStats.crashes, + shardFilesConsidered: searchResponse.zoektStats.shardFilesConsidered, + filesConsidered: searchResponse.zoektStats.filesConsidered, + filesLoaded: searchResponse.zoektStats.filesLoaded, + shardsScanned: searchResponse.zoektStats.shardsScanned, + shardsSkipped: searchResponse.zoektStats.shardsSkipped, + shardsSkippedFilter: searchResponse.zoektStats.shardsSkippedFilter, + ngramMatches: searchResponse.zoektStats.ngramMatches, + ngramLookups: searchResponse.zoektStats.ngramLookups, + wait: searchResponse.zoektStats.wait, + matchTreeConstruction: searchResponse.zoektStats.matchTreeConstruction, + matchTreeSearch: searchResponse.zoektStats.matchTreeSearch, + regexpsConsidered: searchResponse.zoektStats.regexpsConsidered, + flushReason: searchResponse.zoektStats.flushReason, fileLanguages, }); }, [captureEvent, searchQuery, searchResponse]); @@ -151,24 +153,24 @@ const SearchPageInternal = () => { } return { - fileMatches: searchResponse.Result.Files ?? [], + fileMatches: searchResponse.files ?? [], searchDurationMs: Math.round(searchResponse.durationMs), - totalMatchCount: searchResponse.Result.MatchCount, + totalMatchCount: searchResponse.zoektStats.matchCount, isBranchFilteringEnabled: searchResponse.isBranchFilteringEnabled, - repoUrlTemplates: searchResponse.Result.RepoURLs, + repoUrlTemplates: searchResponse.repoUrlTemplates, } }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { - return totalMatchCount > maxMatchDisplayCount; - }, [totalMatchCount, maxMatchDisplayCount]); + return totalMatchCount > matches; + }, [totalMatchCount, matches]); const numMatches = useMemo(() => { // Accumualtes the number of matches across all files return fileMatches.reduce( (acc, file) => - acc + file.ChunkMatches.reduce( - (acc, chunk) => acc + chunk.Ranges.length, + acc + file.chunks.reduce( + (acc, chunk) => acc + chunk.matchRanges.length, 0, ), 0, @@ -178,10 +180,10 @@ const SearchPageInternal = () => { const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], - [SearchQueryParams.maxMatchDisplayCount, `${maxMatchDisplayCount * 2}`], + [SearchQueryParams.matches, `${matches * 2}`], ) router.push(url); - }, [maxMatchDisplayCount, router, searchQuery, domain]); + }, [matches, router, searchQuery, domain]); return (
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 44349cb6..c23d0cde 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -1,9 +1,21 @@ 'use client'; -import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas"; +import { getVersionResponseSchema } from "@/lib/schemas"; import { ServiceError } from "@/lib/serviceError"; -import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types"; +import { GetVersionResponse } from "@/lib/types"; import { isServiceError } from "@/lib/utils"; +import { + FileSourceResponse, + FileSourceRequest, + ListRepositoriesResponse, + SearchRequest, + SearchResponse, +} from "@/features/search/types"; +import { + fileSourceResponseSchema, + listRepositoriesResponseSchema, + searchResponseSchema, +} from "@/features/search/schemas"; export const search = async (body: SearchRequest, domain: string): Promise => { const result = await fetch("/api/search", { diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 829d4f7c..893c3c49 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -1,6 +1,6 @@ 'use server'; -import { listRepositories } from "@/lib/server/searchService"; +import { listRepositories } from "@/features/search/listReposApi"; import { NextRequest } from "next/server"; import { sew, withAuth, withOrgMembership } from "@/actions"; import { isServiceError } from "@/lib/utils"; diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index 54161b0d..d04279a3 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -1,12 +1,12 @@ 'use server'; -import { search } from "@/lib/server/searchService"; -import { searchRequestSchema } from "@/lib/schemas"; +import { search } from "@/features/search/searchApi"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; import { sew, withAuth, withOrgMembership } from "@/actions"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; -import { SearchRequest } from "@/lib/types"; +import { searchRequestSchema } from "@/features/search/schemas"; +import { SearchRequest } from "@/features/search/types"; export const POST = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain")!; diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 19962d50..78522f11 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -1,12 +1,12 @@ 'use server'; -import { fileSourceRequestSchema } from "@/lib/schemas"; -import { getFileSource } from "@/lib/server/searchService"; +import { getFileSource } from "@/features/search/fileSourceApi"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; import { sew, withAuth, withOrgMembership } from "@/actions"; -import { FileSourceRequest } from "@/lib/types"; +import { fileSourceRequestSchema } from "@/features/search/schemas"; +import { FileSourceRequest } from "@/features/search/types"; export const POST = async (request: NextRequest) => { const body = await request.json(); diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts index 1701913a..665b1f17 100644 --- a/packages/web/src/features/entitlements/constants.ts +++ b/packages/web/src/features/entitlements/constants.ts @@ -1,4 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars const planLabels = { oss: "OSS", "cloud:team": "Team", @@ -7,6 +8,7 @@ const planLabels = { export type Plan = keyof typeof planLabels; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const entitlements = [ "search-contexts", "billing" diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts new file mode 100644 index 00000000..87ef4b0a --- /dev/null +++ b/packages/web/src/features/search/fileSourceApi.ts @@ -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 :` 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 => { + 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; +} \ No newline at end of file diff --git a/packages/web/src/features/search/listReposApi.ts b/packages/web/src/features/search/listReposApi.ts new file mode 100644 index 00000000..7baae79c --- /dev/null +++ b/packages/web/src/features/search/listReposApi.ts @@ -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 => { + const body = JSON.stringify({ + opts: { + Field: 0, + } + }); + + let header: Record = {}; + 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); +} \ No newline at end of file diff --git a/packages/web/src/features/search/schemas.ts b/packages/web/src/features/search/schemas.ts new file mode 100644 index 00000000..0b087cb0 --- /dev/null +++ b/packages/web/src/features/search/schemas.ts @@ -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(), +}); \ No newline at end of file diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts new file mode 100644 index 00000000..eb055b50 --- /dev/null +++ b/packages/web/src/features/search/searchApi.ts @@ -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 => { + 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 = {}; + 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); +} diff --git a/packages/web/src/features/search/types.ts b/packages/web/src/features/search/types.ts new file mode 100644 index 00000000..1f652ee4 --- /dev/null +++ b/packages/web/src/features/search/types.ts @@ -0,0 +1,25 @@ +import { + fileSourceResponseSchema, + listRepositoriesResponseSchema, + locationSchema, + searchRequestSchema, + searchResponseSchema, + rangeSchema, + fileSourceRequestSchema, + symbolSchema, +} from "./schemas"; +import { z } from "zod"; + +export type SearchRequest = z.infer; +export type SearchResponse = z.infer; +export type SearchResultRange = z.infer; +export type SearchResultLocation = z.infer; +export type SearchResultFile = SearchResponse["files"][number]; +export type SearchResultChunk = SearchResultFile["chunks"][number]; +export type SearchSymbol = z.infer; + +export type ListRepositoriesResponse = z.infer; +export type Repository = ListRepositoriesResponse["repos"][number]; + +export type FileSourceRequest = z.infer; +export type FileSourceResponse = z.infer; \ No newline at end of file diff --git a/packages/web/src/lib/server/zoektClient.ts b/packages/web/src/features/search/zoektClient.ts similarity index 100% rename from packages/web/src/lib/server/zoektClient.ts rename to packages/web/src/features/search/zoektClient.ts diff --git a/packages/web/src/features/search/zoektSchema.ts b/packages/web/src/features/search/zoektSchema.ts new file mode 100644 index 00000000..d4091fb8 --- /dev/null +++ b/packages/web/src/features/search/zoektSchema.ts @@ -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, + }) +}); \ No newline at end of file diff --git a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts index de2844f8..56325af3 100644 --- a/packages/web/src/lib/extensions/searchResultHighlightExtension.ts +++ b/packages/web/src/lib/extensions/searchResultHighlightExtension.ts @@ -1,6 +1,6 @@ import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; -import { SearchResultRange } from "../types"; +import { SearchResultRange } from "@/features/search/types"; const setMatchState = StateEffect.define<{ selectedMatchIndex: number, @@ -8,9 +8,9 @@ const setMatchState = StateEffect.define<{ }>(); const convertToCodeMirrorRange = (range: SearchResultRange, document: Text) => { - const { Start, End } = range; - const from = document.line(Start.LineNumber).from + Start.Column - 1; - const to = document.line(End.LineNumber).from + End.Column - 1; + const { start, end } = range; + const from = document.line(start.lineNumber).from + start.column - 1; + const to = document.line(end.lineNumber).from + end.column - 1; return { from, to }; } @@ -28,7 +28,7 @@ const matchHighlighter = StateField.define({ const decorations = ranges .sort((a, b) => { - return a.Start.ByteOffset - b.Start.ByteOffset; + return a.start.byteOffset - b.start.byteOffset; }) .map((range, index) => { const { from, to } = convertToCodeMirrorRange(range, transaction.newDoc); diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index a0fc7975..09bdae70 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -3,103 +3,6 @@ import { RepoIndexingStatus } from "@sourcebot/db"; import { z } from "zod"; import { isServiceError } from "./utils"; -export const searchRequestSchema = z.object({ - query: z.string(), - maxMatchDisplayCount: z.number(), - whole: z.boolean().optional(), -}); - - -// @see : https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L212 -export const locationSchema = z.object({ - // 0-based byte offset from the beginning of the file - ByteOffset: z.number(), - // 1-based line number from the beginning of the file - LineNumber: z.number(), - // 1-based column number (in runes) from the beginning of line - Column: z.number(), -}); - -export const rangeSchema = z.object({ - Start: locationSchema, - End: locationSchema, -}); - -// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L350 -export const searchResponseStats = { - ContentBytesLoaded: z.number(), - IndexBytesLoaded: z.number(), - Crashes: z.number(), - Duration: z.number(), - FileCount: z.number(), - ShardFilesConsidered: z.number(), - FilesConsidered: z.number(), - FilesLoaded: z.number(), - FilesSkipped: z.number(), - ShardsScanned: z.number(), - ShardsSkipped: z.number(), - ShardsSkippedFilter: z.number(), - MatchCount: z.number(), - NgramMatches: z.number(), - NgramLookups: z.number(), - Wait: z.number(), - MatchTreeConstruction: z.number(), - MatchTreeSearch: z.number(), - RegexpsConsidered: z.number(), - FlushReason: z.number(), -} - -export const symbolSchema = z.object({ - Sym: z.string(), - Kind: z.string(), - Parent: z.string(), - ParentKind: z.string(), -}); - -// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L497 -export const zoektSearchResponseSchema = z.object({ - Result: z.object({ - ...searchResponseStats, - Files: z.array(z.object({ - FileName: z.string(), - Repository: z.string(), - Version: z.string().optional(), - Language: z.string(), - Branches: z.array(z.string()).optional(), - ChunkMatches: z.array(z.object({ - Content: z.string(), - Ranges: z.array(rangeSchema), - FileName: z.boolean(), - ContentStart: locationSchema, - Score: z.number(), - SymbolInfo: z.array(symbolSchema).nullable(), - })), - Checksum: z.string(), - Score: z.number(), - // Set if `whole` is true. - Content: z.string().optional(), - })).nullable(), - RepoURLs: z.record(z.string(), z.string()), - }), -}); - -export const searchResponseSchema = z.object({ - ...zoektSearchResponseSchema.shape, - // Flag when a branch filter was used (e.g., `branch:`, `revision:`, etc.). - isBranchFilteringEnabled: z.boolean(), -}); - -export const fileSourceRequestSchema = z.object({ - fileName: z.string(), - repository: z.string(), - branch: z.string().optional(), -}); - -export const fileSourceResponseSchema = z.object({ - source: z.string(), - language: z.string(), -}); - export const secretCreateRequestSchema = z.object({ key: z.string(), value: z.string(), @@ -109,62 +12,6 @@ export const secreteDeleteRequestSchema = z.object({ key: z.string(), }); - -// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728 -const repoStatsSchema = z.object({ - Repos: z.number(), - Shards: z.number(), - Documents: z.number(), - IndexBytes: z.number(), - ContentBytes: z.number(), - NewLinesCount: z.number(), - DefaultBranchNewLinesCount: z.number(), - OtherBranchesNewLinesCount: z.number(), -}); - -// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L716 -const indexMetadataSchema = z.object({ - IndexFormatVersion: z.number(), - IndexFeatureVersion: z.number(), - IndexMinReaderVersion: z.number(), - IndexTime: z.string(), - PlainASCII: z.boolean(), - LanguageMap: z.record(z.string(), z.number()), - ZoektVersion: z.string(), - ID: z.string(), -}); - -// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L555 -export const repositorySchema = z.object({ - Name: z.string(), - URL: z.string(), - Source: z.string(), - Branches: z.array(z.object({ - Name: z.string(), - Version: z.string(), - })).nullable(), - CommitURLTemplate: z.string(), - FileURLTemplate: z.string(), - LineFragmentTemplate: z.string(), - RawConfig: z.record(z.string(), z.string()).nullable(), - Rank: z.number(), - IndexOptions: z.string(), - HasSymbols: z.boolean(), - Tombstone: z.boolean(), - LatestCommitDate: z.string(), - FileTombstones: z.string().optional(), -}); - -export const listRepositoriesResponseSchema = z.object({ - List: z.object({ - Repos: z.array(z.object({ - Repository: repositorySchema, - IndexMetadata: indexMetadataSchema, - Stats: repoStatsSchema, - })), - Stats: repoStatsSchema, - }) -}); export const repositoryQuerySchema = z.object({ codeHostType: z.string(), repoId: z.number(), diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts deleted file mode 100644 index 6b978087..00000000 --- a/packages/web/src/lib/server/searchService.ts +++ /dev/null @@ -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 => { - 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 => { - 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 = {}; - 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 :` 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 => { - 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 => { - const body = JSON.stringify({ - opts: { - Field: 0, - } - }); - - let header: Record = {}; - 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; -} \ No newline at end of file diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index b4720577..c1176e87 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -1,31 +1,15 @@ import { z } from "zod"; -import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, repositoryQuerySchema, searchRequestSchema, searchResponseSchema, symbolSchema, getVersionResponseSchema } from "./schemas"; +import { getVersionResponseSchema, repositoryQuerySchema } from "./schemas"; import { tenancyModeSchema } from "@/env.mjs"; export type KeymapType = "default" | "vim"; -export type SearchRequest = z.infer; -export type SearchResponse = z.infer; - -export type SearchResult = SearchResponse["Result"]; -export type SearchResultFile = NonNullable[number]; -export type SearchResultFileMatch = SearchResultFile["ChunkMatches"][number]; -export type SearchResultRange = z.infer; -export type SearchResultLocation = z.infer; - -export type FileSourceRequest = z.infer; -export type FileSourceResponse = z.infer; - -export type ListRepositoriesResponse = z.infer; -export type Repository = z.infer; -export type RepositoryQuery = z.infer; -export type Symbol = z.infer; - export type GetVersionResponse = z.infer; export enum SearchQueryParams { query = "query", - maxMatchDisplayCount = "maxMatchDisplayCount", + matches = "matches", } -export type TenancyMode = z.infer; \ No newline at end of file +export type TenancyMode = z.infer; +export type RepositoryQuery = z.infer; \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 5d1d02a4..3d211ccb 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -6,7 +6,8 @@ import giteaLogo from "@/public/gitea.svg"; import gerritLogo from "@/public/gerrit.svg"; import bitbucketLogo from "@/public/bitbucket.svg"; import { ServiceError } from "./serviceError"; -import { Repository, RepositoryQuery } from "./types"; +import { RepositoryQuery } from "./types"; +import { Repository } from "@/features/search/types"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -48,15 +49,15 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined return undefined; } - if (!repo.RawConfig) { + if (!repo.rawConfig) { return undefined; } // @todo : use zod to validate config schema - const webUrlType = repo.RawConfig['web-url-type']!; - const displayName = repo.RawConfig['display-name'] ?? repo.RawConfig['name']!; + const webUrlType = repo.rawConfig['web-url-type']!; + const displayName = repo.rawConfig['display-name'] ?? repo.rawConfig['name']!; - return _getCodeHostInfoInternal(webUrlType, displayName, repo.URL); + return _getCodeHostInfoInternal(webUrlType, displayName, repo.url); } export const getRepoQueryCodeHostInfo = (repo: RepositoryQuery): CodeHostInfo | undefined => { diff --git a/yarn.lock b/yarn.lock index 6824f5fd..af3c85c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5343,6 +5343,7 @@ __metadata: codemirror-lang-sparql: "npm:^2.0.0" codemirror-lang-spreadsheet: "npm:^1.3.0" codemirror-lang-zig: "npm:^0.1.0" + cross-env: "npm:^7.0.3" embla-carousel-auto-scroll: "npm:^8.3.0" embla-carousel-react: "npm:^8.3.0" escape-string-regexp: "npm:^5.0.0"