Symbol suggestions (#98)

This commit is contained in:
Brendan Kellam 2024-11-28 13:26:27 -08:00 committed by GitHub
parent b115218be9
commit 120d84a046
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 201 additions and 75 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added symbol suggestions as suggestion type. ([#98](https://github.com/sourcebot-dev/sourcebot/pull/98))
## [2.5.2] - 2024-11-27
### Fixed

View file

@ -392,3 +392,9 @@ Or if you are [building locally](#build-from-source), create a `.env.local` file
SOURCEBOT_TELEMETRY_DISABLED=1
NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED=1
```
## Attributions
Sourcebot makes use of the following libraries:
- [@vscode/codicons](https://github.com/microsoft/vscode-codicons) under the [CC BY 4.0 License](https://github.com/microsoft/vscode-codicons/blob/main/LICENSE).

View file

@ -59,6 +59,7 @@
"react-dom": "^18",
"react-hook-form": "^7.53.0",
"react-hotkeys-hook": "^4.5.1",
"react-icons": "^5.3.0",
"react-resizable-panels": "^2.1.1",
"server-only": "^0.0.1",
"sharp": "^0.33.5",

View file

@ -255,13 +255,16 @@ export const SearchBar = ({
setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
}}
cursorPosition={cursorPosition}
data={suggestionData}
onSuggestionModeChanged={(suggestionMode) => {
setSuggestionMode(suggestionMode);
onSuggestionModeChanged={(newSuggestionMode) => {
if (suggestionMode !== newSuggestionMode) {
console.debug(`Suggestion mode changed: ${suggestionMode} -> ${newSuggestionMode}`);
}
setSuggestionMode(newSuggestionMode);
}}
onSuggestionQueryChanged={(suggestionQuery) => {
setSuggestionQuery(suggestionQuery);
}}
{...suggestionData}
/>
</div>
)

View file

@ -1,8 +1,6 @@
'use client';
import { isDefined } from "@/lib/utils";
import { CommitIcon, MixerVerticalIcon } from "@radix-ui/react-icons";
import { IconProps } from "@radix-ui/react-icons/dist/types";
import assert from "assert";
import clsx from "clsx";
import escapeStringRegexp from "escape-string-regexp";
@ -16,13 +14,14 @@ import {
refineModeSuggestions,
suggestionModeMappings
} from "./constants";
type Icon = React.ForwardRefExoticComponent<IconProps & React.RefAttributes<SVGSVGElement>>;
import { IconType } from "react-icons/lib";
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
export type Suggestion = {
value: string;
description?: string;
spotlight?: boolean;
Icon?: IconType;
}
export type SuggestionMode =
@ -50,18 +49,17 @@ interface SearchSuggestionsBoxProps {
onSuggestionModeChanged: (suggestionMode: SuggestionMode) => void;
onSuggestionQueryChanged: (suggestionQuery: string) => void;
data: {
repos: Suggestion[];
languages: Suggestion[];
files: Suggestion[];
}
isLoadingSuggestions: boolean;
repoSuggestions: Suggestion[];
fileSuggestions: Suggestion[];
symbolSuggestions: Suggestion[];
languageSuggestions: Suggestion[];
}
const SearchSuggestionsBox = forwardRef(({
query,
onCompletion,
isEnabled,
data,
cursorPosition,
isFocused,
onFocus,
@ -69,11 +67,24 @@ const SearchSuggestionsBox = forwardRef(({
onReturnFocus,
onSuggestionModeChanged,
onSuggestionQueryChanged,
isLoadingSuggestions,
repoSuggestions,
fileSuggestions,
symbolSuggestions,
languageSuggestions,
}: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => {
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0);
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery?: string, suggestionMode?: SuggestionMode }>(() => {
// Only re-calculate the suggestion mode and query if the box is enabled.
// This is to avoid transitioning the suggestion mode and causing a fetch
// when it is not needed.
// @see: useSuggestionsData.ts
if (!isEnabled) {
return {};
}
const { queryParts, cursorIndex } = splitQuery(query, cursorPosition);
if (queryParts.length === 0) {
return {};
@ -107,10 +118,10 @@ const SearchSuggestionsBox = forwardRef(({
suggestionQuery: part,
suggestionMode: "refine",
}
}, [cursorPosition, query]);
}, [cursorPosition, isEnabled, query]);
const { suggestions, isHighlightEnabled, Icon, onSuggestionClicked } = useMemo(() => {
if (!isDefined(suggestionQuery) || !isDefined(suggestionMode)) {
const { suggestions, isHighlightEnabled, DefaultIcon, onSuggestionClicked } = useMemo(() => {
if (!isEnabled || !isDefined(suggestionQuery) || !isDefined(suggestionMode)) {
return {};
}
@ -144,7 +155,7 @@ const SearchSuggestionsBox = forwardRef(({
isSpotlightEnabled = false,
isClientSideSearchEnabled = true,
onSuggestionClicked,
Icon,
DefaultIcon,
} = ((): {
threshold?: number,
limit?: number,
@ -153,7 +164,7 @@ const SearchSuggestionsBox = forwardRef(({
isSpotlightEnabled?: boolean,
isClientSideSearchEnabled?: boolean,
onSuggestionClicked: (value: string) => void,
Icon?: Icon
DefaultIcon?: IconType
} => {
switch (suggestionMode) {
case "public":
@ -178,13 +189,13 @@ const SearchSuggestionsBox = forwardRef(({
}
case "repo":
return {
list: data.repos,
Icon: CommitIcon,
list: repoSuggestions,
DefaultIcon: VscRepo,
onSuggestionClicked: createOnSuggestionClickedHandler({ regexEscaped: true }),
}
case "language": {
return {
list: data.languages,
list: languageSuggestions,
onSuggestionClicked: createOnSuggestionClickedHandler(),
isSpotlightEnabled: true,
}
@ -195,18 +206,25 @@ const SearchSuggestionsBox = forwardRef(({
list: refineModeSuggestions,
isHighlightEnabled: true,
isSpotlightEnabled: true,
Icon: MixerVerticalIcon,
DefaultIcon: VscFilter,
onSuggestionClicked: createOnSuggestionClickedHandler({ trailingSpace: false }),
}
case "file":
return {
list: data.files,
list: fileSuggestions,
onSuggestionClicked: createOnSuggestionClickedHandler(),
isClientSideSearchEnabled: false,
DefaultIcon: VscFile,
}
case "symbol":
return {
list: symbolSuggestions,
onSuggestionClicked: createOnSuggestionClickedHandler(),
isClientSideSearchEnabled: false,
DefaultIcon: VscSymbolMisc,
}
case "revision":
case "content":
case "symbol":
return {
list: [],
onSuggestionClicked: createOnSuggestionClickedHandler(),
@ -252,11 +270,11 @@ const SearchSuggestionsBox = forwardRef(({
return {
suggestions,
isHighlightEnabled,
Icon,
DefaultIcon,
onSuggestionClicked,
}
}, [suggestionQuery, suggestionMode, query, cursorPosition, onCompletion, data.repos, data.files, data.languages]);
}, [isEnabled, suggestionQuery, suggestionMode, query, cursorPosition, onCompletion, repoSuggestions, fileSuggestions, symbolSuggestions, languageSuggestions]);
// When the list of suggestions change, reset the highlight index
useEffect(() => {
@ -283,7 +301,13 @@ const SearchSuggestionsBox = forwardRef(({
case "repo":
return "Repositories";
case "refine":
return "Refine search"
return "Refine search";
case "file":
return "Files";
case "symbol":
return "Symbols";
case "language":
return "Languages";
default:
return "";
}
@ -291,12 +315,15 @@ const SearchSuggestionsBox = forwardRef(({
if (
!isEnabled ||
!suggestions ||
suggestions.length === 0
!suggestions
) {
return null;
}
if (suggestions.length === 0 && !isLoadingSuggestions) {
return null;
}
return (
<div
ref={ref}
@ -305,6 +332,9 @@ const SearchSuggestionsBox = forwardRef(({
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
if (highlightedSuggestionIndex < 0 || highlightedSuggestionIndex >= suggestions.length) {
return;
}
const value = suggestions[highlightedSuggestionIndex].value;
onSuggestionClicked(value);
}
@ -334,7 +364,17 @@ const SearchSuggestionsBox = forwardRef(({
<p className="text-muted-foreground text-sm mb-1">
{suggestionModeText}
</p>
{suggestions.map((result, index) => (
{isLoadingSuggestions ? (
// Skeleton placeholder
<div className="animate-pulse flex flex-col gap-2 px-1 py-0.5">
{
Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="h-4 bg-muted rounded-md w-full"></div>
))
}
</div>
) : suggestions.map((result, index) => (
// Suggestion list
<div
key={index}
className={clsx("flex flex-row items-center font-mono text-sm hover:bg-muted rounded-md px-1 py-0.5 cursor-pointer", {
@ -345,23 +385,24 @@ const SearchSuggestionsBox = forwardRef(({
onSuggestionClicked(result.value)
}}
>
{Icon && (
<Icon className="w-3 h-3 mr-2" />
)}
<div className="flex flex-row items-center">
<span
className={clsx('mr-2 flex-none', {
"text-highlight": isHighlightEnabled
})}
>
{result.value}
{result.Icon ? (
<result.Icon className="w-3 h-3 mr-2 flex-none" />
) : DefaultIcon ? (
<DefaultIcon className="w-3 h-3 mr-2 flex-none" />
) : null}
<span
className={clsx('mr-2', {
"text-highlight": isHighlightEnabled,
"truncate": !result.description,
})}
>
{result.value}
</span>
{result.description && (
<span className="text-muted-foreground font-light">
{result.description}
</span>
{result.description && (
<span className="text-muted-foreground font-light">
{result.description}
</span>
)}
</div>
)}
</div>
))}
{isFocused && (

View file

@ -4,7 +4,20 @@ import { useQuery } from "@tanstack/react-query";
import { Suggestion, SuggestionMode } from "./searchSuggestionsBox";
import { getRepos, search } from "@/app/api/(client)/client";
import { useMemo } from "react";
import { Symbol } from "@/lib/types";
import languages from "./languages";
import {
VscSymbolClass,
VscSymbolConstant,
VscSymbolEnum,
VscSymbolField,
VscSymbolInterface,
VscSymbolMethod,
VscSymbolProperty,
VscSymbolStructure,
VscSymbolVariable
} from "react-icons/vsc";
interface Props {
suggestionMode: SuggestionMode;
@ -18,7 +31,7 @@ export const useSuggestionsData = ({
suggestionMode,
suggestionQuery,
}: Props) => {
const { data: repoSuggestions } = useQuery({
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
queryKey: ["repoSuggestions"],
queryFn: getRepos,
select: (data): Suggestion[] => {
@ -30,8 +43,9 @@ export const useSuggestionsData = ({
},
enabled: suggestionMode === "repo",
});
const isLoadingRepos = useMemo(() => suggestionMode === "repo" && _isLoadingRepos, [_isLoadingRepos, suggestionMode]);
const { data: fileSuggestions } = useQuery({
const { data: fileSuggestions, isLoading: _isLoadingFiles } = useQuery({
queryKey: ["fileSuggestions", suggestionQuery],
queryFn: () => search({
query: `file:${suggestionQuery}`,
@ -44,6 +58,32 @@ export const useSuggestionsData = ({
},
enabled: suggestionMode === "file"
});
const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFiles, [_isLoadingFiles, suggestionMode]);
const { data: symbolSuggestions, isLoading: _isLoadingSymbols } = useQuery({
queryKey: ["symbolSuggestions", suggestionQuery],
queryFn: () => search({
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
maxMatchDisplayCount: 15,
}),
select: (data): Suggestion[] => {
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
if (!symbols) {
return [];
}
// De-duplicate on symbol name & kind.
const symbolMap = new Map<string, Symbol>(symbols.map((symbol: Symbol) => [`${symbol.Kind}.${symbol.Sym}`, symbol]));
const suggestions = Array.from(symbolMap.values()).map((symbol) => ({
value: symbol.Sym,
Icon: getSymbolIcon(symbol),
} satisfies Suggestion));
return suggestions;
},
enabled: suggestionMode === "symbol",
});
const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]);
const languageSuggestions = useMemo((): Suggestion[] => {
return languages.map((lang) => {
@ -63,13 +103,45 @@ export const useSuggestionsData = ({
});
}, []);
const data = useMemo(() => {
return {
repos: repoSuggestions ?? [],
languages: languageSuggestions,
files: fileSuggestions ?? [],
}
}, [repoSuggestions, fileSuggestions, languageSuggestions]);
const isLoadingSuggestions = useMemo(() => {
return isLoadingSymbols || isLoadingFiles || isLoadingRepos;
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]);
return data;
return {
repoSuggestions: repoSuggestions ?? [],
fileSuggestions: fileSuggestions ?? [],
symbolSuggestions: symbolSuggestions ?? [],
languageSuggestions,
isLoadingSuggestions,
}
}
const getSymbolIcon = (symbol: Symbol) => {
switch (symbol.Kind) {
case "methodSpec":
case "method":
case "function":
case "func":
return VscSymbolMethod;
case "variable":
return VscSymbolVariable;
case "class":
return VscSymbolClass;
case "const":
case "macro":
case "constant":
return VscSymbolConstant;
case "property":
return VscSymbolProperty;
case "struct":
return VscSymbolStructure;
case "field":
case "member":
return VscSymbolField;
case "interface":
return VscSymbolInterface;
case "enum":
case "enumerator":
return VscSymbolEnum;
}
}

View file

@ -46,6 +46,13 @@ export const searchResponseStats = {
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({
@ -62,6 +69,7 @@ export const zoektSearchResponseSchema = z.object({
FileName: z.boolean(),
ContentStart: locationSchema,
Score: z.number(),
SymbolInfo: z.array(symbolSchema).nullable(),
})),
Checksum: z.string(),
Score: z.number(),

View file

@ -1,5 +1,5 @@
import { z } from "zod";
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, searchRequestSchema, searchResponseSchema } from "./schemas";
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, searchRequestSchema, searchResponseSchema, symbolSchema } from "./schemas";
export type KeymapType = "default" | "vim";
@ -18,6 +18,8 @@ export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
export type Repository = z.infer<typeof repositorySchema>;
export type Symbol = z.infer<typeof symbolSchema>;
export enum SearchQueryParams {
query = "query",
maxMatchDisplayCount = "maxMatchDisplayCount",

View file

@ -4604,6 +4604,11 @@ react-hotkeys-hook@^4.5.1:
resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-4.5.1.tgz#990260ecc7e5a431414148a93b02a2f1a9707897"
integrity sha512-scAEJOh3Irm0g95NIn6+tQVf/OICCjsQsC9NBHfQws/Vxw4sfq1tDQut5fhTEvPraXhu/sHxRd9lOtxzyYuNAg==
react-icons@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.3.0.tgz#ccad07a30aebd40a89f8cfa7d82e466019203f1c"
integrity sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -5075,16 +5080,7 @@ string-argv@^0.3.1:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6"
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -5181,14 +5177,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0:
dependencies:
safe-buffer "~5.2.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==