diff --git a/CHANGELOG.md b/CHANGELOG.md index e2093858..2b7b05dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b42d78d5..c26e9b86 100644 --- a/README.md +++ b/README.md @@ -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). \ No newline at end of file diff --git a/packages/web/package.json b/packages/web/package.json index 81ae488d..6f468e24 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/src/app/components/searchBar/searchBar.tsx b/packages/web/src/app/components/searchBar/searchBar.tsx index 49cb7dc9..cd8c859a 100644 --- a/packages/web/src/app/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/components/searchBar/searchBar.tsx @@ -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} /> ) diff --git a/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx index e2cd0ecd..8c54bdf0 100644 --- a/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx @@ -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>; +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) => { 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 (
{ 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(({

{suggestionModeText}

- {suggestions.map((result, index) => ( + {isLoadingSuggestions ? ( + // Skeleton placeholder +
+ { + Array.from({ length: 10 }).map((_, index) => ( +
+ )) + } +
+ ) : suggestions.map((result, index) => ( + // Suggestion list
- {Icon && ( - - )} -
- - {result.value} + {result.Icon ? ( + + ) : DefaultIcon ? ( + + ) : null} + + {result.value} + + {result.description && ( + + {result.description} - {result.description && ( - - {result.description} - - )} -
+ )}
))} {isFocused && ( diff --git a/packages/web/src/app/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/components/searchBar/useSuggestionsData.ts index 6ab9a82c..35d5b38e 100644 --- a/packages/web/src/app/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/components/searchBar/useSuggestionsData.ts @@ -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(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; + } } \ No newline at end of file diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 9fcb2b0d..e1741644 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -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(), diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 3d5d6c17..c1d8bccd 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -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; export type ListRepositoriesResponse = z.infer; export type Repository = z.infer; +export type Symbol = z.infer; + export enum SearchQueryParams { query = "query", maxMatchDisplayCount = "maxMatchDisplayCount", diff --git a/yarn.lock b/yarn.lock index 27b144db..f08ff914 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==