@@ -114,9 +124,18 @@ export const FileMatchContainer = ({
>
{repoName}
+ {isBranchFilteringEnabled && branches.length > 0 && (
+
+ {`@ ${branches[0]}`}
+ {branches.length > 1 && ` (+ ${branches.length - 1})`}
+
+ )}
ยท
-
+
{!fileNameRange ?
file.FileName
: (
diff --git a/packages/web/src/app/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/search/components/searchResultsPanel/index.tsx
index 717c335b..fb9de7d3 100644
--- a/packages/web/src/app/search/components/searchResultsPanel/index.tsx
+++ b/packages/web/src/app/search/components/searchResultsPanel/index.tsx
@@ -11,6 +11,7 @@ interface SearchResultsPanelProps {
onMatchIndexChanged: (matchIndex: number) => void;
isLoadMoreButtonVisible: boolean;
onLoadMoreButtonClicked: () => void;
+ isBranchFilteringEnabled: boolean;
}
const ESTIMATED_LINE_HEIGHT_PX = 20;
@@ -23,6 +24,7 @@ export const SearchResultsPanel = ({
onMatchIndexChanged,
isLoadMoreButtonVisible,
onLoadMoreButtonClicked,
+ isBranchFilteringEnabled,
}: SearchResultsPanelProps) => {
const parentRef = useRef(null);
const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false));
@@ -145,6 +147,7 @@ export const SearchResultsPanel = ({
onShowAllMatchesButtonClicked={() => {
onShowAllMatchesButtonClicked(virtualRow.index);
}}
+ isBranchFilteringEnabled={isBranchFilteringEnabled}
/>
))}
diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx
index 5f8563af..7c787213 100644
--- a/packages/web/src/app/search/page.tsx
+++ b/packages/web/src/app/search/page.tsx
@@ -77,35 +77,55 @@ export default function SearchPage() {
});
}, [captureEvent, searchResponse]);
- const { fileMatches, searchDurationMs } = useMemo((): { fileMatches: SearchResultFile[], searchDurationMs: number } => {
+ const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled } = useMemo(() => {
if (!searchResponse) {
return {
fileMatches: [],
searchDurationMs: 0,
+ totalMatchCount: 0,
+ isBranchFilteringEnabled: false,
};
}
+ const isBranchFilteringEnabled = searchResponse.isBranchFilteringEnabled;
+ let fileMatches = searchResponse.Result.Files ?? [];
+
+ // We only want to show matches for the default branch when
+ // the user isn't explicitly filtering by branch.
+ if (!isBranchFilteringEnabled) {
+ fileMatches = fileMatches.filter(match => {
+ // @note : this case handles local repos that don't have any branches.
+ if (!match.Branches) {
+ return true;
+ }
+
+ return match.Branches.includes("HEAD");
+ });
+ }
+
return {
- fileMatches: searchResponse.Result.Files ?? [],
+ fileMatches,
searchDurationMs: Math.round(searchResponse.Result.Duration / 1000000),
+ totalMatchCount: searchResponse.Result.MatchCount,
+ isBranchFilteringEnabled,
}
- }, [searchResponse]);
+ }, [searchResponse, searchQuery]);
const isMoreResultsButtonVisible = useMemo(() => {
- return searchResponse && searchResponse.Result.MatchCount > maxMatchDisplayCount;
- }, [searchResponse, maxMatchDisplayCount]);
+ return totalMatchCount > maxMatchDisplayCount;
+ }, [totalMatchCount, maxMatchDisplayCount]);
const numMatches = useMemo(() => {
// Accumualtes the number of matches across all files
- return searchResponse?.Result.Files?.reduce(
+ return fileMatches.reduce(
(acc, file) =>
acc + file.ChunkMatches.reduce(
(acc, chunk) => acc + chunk.Ranges.length,
0,
),
0,
- ) ?? 0;
- }, [searchResponse]);
+ );
+ }, [fileMatches]);
const onLoadMoreResults = useCallback(() => {
const url = createPathWithQueryParams('/search',
@@ -151,8 +171,8 @@ export default function SearchPage() {
{!isLoading && (
{
- fileMatches.length > 0 && searchResponse ? (
-
{`[${searchDurationMs} ms] Displaying ${numMatches} of ${searchResponse.Result.MatchCount} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}
+ fileMatches.length > 0 ? (
+
{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}
) : (
No results
)
@@ -180,6 +200,7 @@ export default function SearchPage() {
fileMatches={fileMatches}
isMoreResultsButtonVisible={isMoreResultsButtonVisible}
onLoadMoreResults={onLoadMoreResults}
+ isBranchFilteringEnabled={isBranchFilteringEnabled}
/>
)}
@@ -190,12 +211,14 @@ interface PanelGroupProps {
fileMatches: SearchResultFile[];
isMoreResultsButtonVisible?: boolean;
onLoadMoreResults: () => void;
+ isBranchFilteringEnabled: boolean;
}
const PanelGroup = ({
fileMatches,
isMoreResultsButtonVisible,
onLoadMoreResults,
+ isBranchFilteringEnabled,
}: PanelGroupProps) => {
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [selectedFile, setSelectedFile] = useState
(undefined);
@@ -253,6 +276,7 @@ const PanelGroup = ({
}}
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
onLoadMoreButtonClicked={onLoadMoreResults}
+ isBranchFilteringEnabled={isBranchFilteringEnabled}
/>
) : (
diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts
index cac793cf..f401b765 100644
--- a/packages/web/src/lib/schemas.ts
+++ b/packages/web/src/lib/schemas.ts
@@ -47,7 +47,7 @@ export const searchResponseStats = {
}
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L497
-export const searchResponseSchema = z.object({
+export const zoektSearchResponseSchema = z.object({
Result: z.object({
...searchResponseStats,
Files: z.array(z.object({
@@ -71,9 +71,16 @@ export const searchResponseSchema = z.object({
}),
});
+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()
+ repository: z.string(),
+ branch: z.string().optional(),
});
export const fileSourceResponseSchema = z.object({
diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts
index 1e299b0b..fc40dad5 100644
--- a/packages/web/src/lib/server/searchService.ts
+++ b/packages/web/src/lib/server/searchService.ts
@@ -1,12 +1,45 @@
import escapeStringRegexp from "escape-string-regexp";
import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment";
-import { listRepositoriesResponseSchema, searchResponseSchema } from "../schemas";
+import { listRepositoriesResponseSchema, searchResponseSchema, 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";
+// 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:",
+}
+
+// Mapping of additional "alias" prefixes to zoekt prefixes.
+const aliasPrefixMappings: Record = {
+ "rev:": zoektPrefixes.branch,
+ "revision:": zoektPrefixes.branch,
+}
+
export const search = async ({ query, maxMatchDisplayCount, whole }: SearchRequest): Promise => {
+ // Replace any alias prefixes with their corresponding zoekt prefixes.
+ for (const [prefix, zoektPrefix] of Object.entries(aliasPrefixMappings)) {
+ query = query.replaceAll(prefix, zoektPrefix);
+ }
+
const body = JSON.stringify({
q: query,
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892
@@ -31,21 +64,34 @@ export const search = async ({ query, maxMatchDisplayCount, whole }: SearchReque
}
const searchBody = await searchResponse.json();
- const parsedSearchResponse = searchResponseSchema.safeParse(searchBody);
+ 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;
+ const isBranchFilteringEnabled = (
+ query.includes(zoektPrefixes.branch) ||
+ query.includes(zoektPrefixes.branchShort)
+ )
+
+ return {
+ ...parsedSearchResponse.data,
+ isBranchFilteringEnabled,
+ }
}
-export const getFileSource = async ({ fileName, repository }: FileSourceRequest): Promise => {
+export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest): 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: `file:${escapedFileName} repo:^${escapedRepository}$`,
+ query,
maxMatchDisplayCount: 1,
whole: true,
});
diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts
index a1652379..3d5d6c17 100644
--- a/packages/web/src/lib/types.ts
+++ b/packages/web/src/lib/types.ts
@@ -3,7 +3,9 @@ import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResp
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];
@@ -15,7 +17,6 @@ export type FileSourceResponse = z.infer;
export type ListRepositoriesResponse = z.infer;
export type Repository = z.infer;
-export type SearchRequest = z.infer;
export enum SearchQueryParams {
query = "query",
diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts
index 38846d5f..3e374af5 100644
--- a/packages/web/src/lib/utils.ts
+++ b/packages/web/src/lib/utils.ts
@@ -73,19 +73,19 @@ export const getRepoCodeHostInfo = (repoName: string): CodeHostInfo | undefined
return undefined;
}
-export const getCodeHostFilePreviewLink = (repoName: string, filePath: string): string | undefined => {
+export const getCodeHostFilePreviewLink = (repoName: string, filePath: string, branch: string = "HEAD"): string | undefined => {
const info = getRepoCodeHostInfo(repoName);
if (info?.type === "github") {
- return `${info.repoLink}/blob/HEAD/${filePath}`;
+ return `${info.repoLink}/blob/${branch}/${filePath}`;
}
if (info?.type === "gitlab") {
- return `${info.repoLink}/-/blob/HEAD/${filePath}`;
+ return `${info.repoLink}/-/blob/${branch}/${filePath}`;
}
if (info?.type === "gitea") {
- return `${info.repoLink}/src/branch/HEAD/${filePath}`;
+ return `${info.repoLink}/src/branch/${branch}/${filePath}`;
}
return undefined;
diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts
index 84287e82..8b7dd592 100644
--- a/packages/web/tailwind.config.ts
+++ b/packages/web/tailwind.config.ts
@@ -74,7 +74,10 @@ const config = {
},
},
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [
+ require("tailwindcss-animate"),
+ require('tailwind-scrollbar-hide')
+ ],
} satisfies Config
export default config
\ No newline at end of file
diff --git a/schemas/v2/index.json b/schemas/v2/index.json
index 9cdfb6f9..256fd3cd 100644
--- a/schemas/v2/index.json
+++ b/schemas/v2/index.json
@@ -24,6 +24,47 @@
}
]
},
+ "GitRevisions": {
+ "type": "object",
+ "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.",
+ "properties": {
+ "branches": {
+ "type": "array",
+ "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "main",
+ "release/*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [
+ [
+ "latest",
+ "v2.*.*"
+ ],
+ [
+ "**"
+ ]
+ ],
+ "default": []
+ }
+ },
+ "additionalProperties": false
+ },
"GitHubConfig": {
"type": "object",
"properties": {
@@ -113,6 +154,9 @@
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "$ref": "#/definitions/GitRevisions"
}
},
"required": [
@@ -207,6 +251,9 @@
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "$ref": "#/definitions/GitRevisions"
}
},
"required": [
@@ -297,6 +344,9 @@
}
},
"additionalProperties": false
+ },
+ "revisions": {
+ "$ref": "#/definitions/GitRevisions"
}
},
"required": [
diff --git a/yarn.lock b/yarn.lock
index ddca8307..3f3d29e9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1327,6 +1327,11 @@
resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-2.0.16.tgz#3bb7ccd2844b3a8bcd6efbd217f6c0ea06a80d22"
integrity sha512-aMqBra2JlqpFeCWOinCtpRpiCkPIXH8hahW2+FkGzvWjfE5sAqtOcrjN5DRcMnTQqFDe6gb1CVYuGnBH0lhXwA==
+"@types/braces@*":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.4.tgz#403488dc1c8d0db288270d3bbf0ce5f9c45678b4"
+ integrity sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==
+
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
@@ -1342,6 +1347,13 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.10.tgz#64f3edf656af2fe59e7278b73d3e62404144a6e6"
integrity sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==
+"@types/micromatch@^4.0.9":
+ version "4.0.9"
+ resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.9.tgz#8e5763a8c1fc7fbf26144d9215a01ab0ff702dbb"
+ integrity sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==
+ dependencies:
+ "@types/braces" "*"
+
"@types/node@^20":
version "20.16.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.16.10.tgz#0cc3fdd3daf114a4776f54ba19726a01c907ef71"
@@ -3444,7 +3456,7 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-micromatch@^4.0.4, micromatch@^4.0.5:
+micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -4544,6 +4556,11 @@ tailwind-merge@^2.5.2:
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.3.tgz#579546e14ddda24462e0303acd8798c50f5511bb"
integrity sha512-d9ZolCAIzom1nf/5p4LdD5zvjmgSxY0BGgdSvmXIoMYAiPdAW/dSpP7joCDYFY7r/HkEa2qmPtkgsu0xjQeQtw==
+tailwind-scrollbar-hide@^1.1.7:
+ version "1.1.7"
+ resolved "https://registry.yarnpkg.com/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz#90b481fb2e204030e3919427416650c54f56f847"
+ integrity sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==
+
tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"