diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..897af65d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f65bac5..463b90ca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,10 @@ "files.associations": { "*.json": "jsonc", "index.json": "json" - } + }, + "eslint.workingDirectories": [ + { + "pattern": "./packages/*/" + } + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c008bf6..a8e6cff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed issue with GitLab sub-projects not being included recursively. ([#54](https://github.com/sourcebot-dev/sourcebot/pull/54)) +- Fixed slow rendering performance when rendering a large number of results. ([#52](https://github.com/sourcebot-dev/sourcebot/pull/52)) ## [2.1.1] - 2024-10-25 diff --git a/packages/web/package.json b/packages/web/package.json index cc523f8e..672758d2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -38,6 +38,7 @@ "@replit/codemirror-vim": "^6.2.1", "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", + "@tanstack/react-virtual": "^3.10.8", "@uiw/react-codemirror": "^4.23.0", "class-variance-authority": "^0.7.0", "client-only": "^0.0.1", diff --git a/packages/web/src/app/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/search/components/codePreviewPanel/index.tsx index 6921663f..40df1ff9 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/search/components/codePreviewPanel/index.tsx @@ -59,5 +59,4 @@ export const CodePreviewPanel = ({ onSelectedMatchIndexChange={onSelectedMatchIndexChange} /> ) - } \ No newline at end of file diff --git a/packages/web/src/app/search/components/filterPanel/index.tsx b/packages/web/src/app/search/components/filterPanel/index.tsx index 1a220fe3..d459799a 100644 --- a/packages/web/src/app/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/search/components/filterPanel/index.tsx @@ -93,7 +93,7 @@ export const FilterPanel = ({ ); onFilterChanged(filteredMatches); - }, [matches, repos, languages]); + }, [matches, repos, languages, onFilterChanged]); return (
diff --git a/packages/web/src/app/search/components/searchResultsPanel/codePreview.tsx b/packages/web/src/app/search/components/searchResultsPanel/codePreview.tsx index e7ebb7ed..d1759401 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/codePreview.tsx +++ b/packages/web/src/app/search/components/searchResultsPanel/codePreview.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency"; -import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; -import { useThemeNormalized } from "@/hooks/useThemeNormalized"; +import { getSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension"; import { SearchResultRange } from "@/lib/types"; -import CodeMirror, { Decoration, DecorationSet, EditorState, EditorView, ReactCodeMirrorRef, StateField, Transaction } from "@uiw/react-codemirror"; +import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorState, StateField, Transaction } from "@codemirror/state"; +import { defaultLightThemeOption, oneDarkHighlightStyle, oneDarkTheme } from "@uiw/react-codemirror"; +import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view"; import { useMemo, useRef } from "react"; +import { LightweightCodeMirror, CodeMirrorRef } from "./lightweightCodeMirror"; +import { useThemeNormalized } from "@/hooks/useThemeNormalized"; const markDecoration = Decoration.mark({ class: "cm-searchMatch-selected" @@ -25,13 +28,22 @@ export const CodePreview = ({ ranges, lineOffset, }: CodePreviewProps) => { - const editorRef = useRef(null); + const editorRef = useRef(null); const { theme } = useThemeNormalized(); - const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); - - const rangeHighlighting = useExtensionWithDependency(editorRef.current?.view ?? null, () => { + const extensions = useMemo(() => { return [ + EditorView.editable.of(false), + ...(theme === 'dark' ? [ + syntaxHighlighting(oneDarkHighlightStyle), + oneDarkTheme, + ] : [ + syntaxHighlighting(defaultHighlightStyle), + defaultLightThemeOption, + ]), + lineNumbers(), + lineOffsetExtension(lineOffset), + getSyntaxHighlightingExtension(language), StateField.define({ create(editorState: EditorState) { const document = editorState.doc; @@ -61,7 +73,8 @@ export const CodePreview = ({ 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); return Decoration.set(decorations); }, @@ -70,56 +83,15 @@ export const CodePreview = ({ }, provide: (field) => EditorView.decorations.from(field), }), - ]; - }, [ranges, lineOffset]); - - const extensions = useMemo(() => { - return [ - syntaxHighlighting, - EditorView.lineWrapping, - lineOffsetExtension(lineOffset), - rangeHighlighting, - ]; - }, [syntaxHighlighting, lineOffset, rangeHighlighting]); + ] + }, [language, lineOffset, ranges, theme]); return ( - ) + } \ No newline at end of file diff --git a/packages/web/src/app/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/search/components/searchResultsPanel/fileMatch.tsx index 635fb5b9..981ad746 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/fileMatch.tsx +++ b/packages/web/src/app/search/components/searchResultsPanel/fileMatch.tsx @@ -29,7 +29,7 @@ export const FileMatch = ({ return (
{ if (e.key !== "Enter") { return; diff --git a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx index 02951f49..b7c7370f 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { getRepoCodeHostInfo } from "@/lib/utils"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import Image from "next/image"; import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; @@ -9,21 +9,24 @@ import { Separator } from "@/components/ui/separator"; import { SearchResultFile } from "@/lib/types"; import { FileMatch } from "./fileMatch"; -const MAX_MATCHES_TO_PREVIEW = 3; +export const MAX_MATCHES_TO_PREVIEW = 3; interface FileMatchContainerProps { file: SearchResultFile; onOpenFile: () => void; onMatchIndexChanged: (matchIndex: number) => void; + showAllMatches: boolean; + onShowAllMatchesButtonClicked: () => void; } export const FileMatchContainer = ({ file, onOpenFile, onMatchIndexChanged, + showAllMatches, + onShowAllMatchesButtonClicked, }: FileMatchContainerProps) => { - const [showAll, setShowAll] = useState(false); const matchCount = useMemo(() => { return file.ChunkMatches.length; }, [file]); @@ -33,12 +36,12 @@ export const FileMatchContainer = ({ return a.ContentStart.LineNumber - b.ContentStart.LineNumber; }); - if (!showAll) { + if (!showAllMatches) { return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW); } return sortedMatches; - }, [file, showAll]); + }, [file, showAllMatches]); const fileNameRange = useMemo(() => { for (const match of matches) { @@ -79,10 +82,6 @@ export const FileMatchContainer = ({ return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); - const onShowMoreMatches = useCallback(() => { - setShowAll(!showAll); - }, [showAll]); - const onOpenMatch = useCallback((index: number) => { const matchIndex = matches.slice(0, index).reduce((acc, match) => { return acc + match.Ranges.length; @@ -94,8 +93,9 @@ export const FileMatchContainer = ({ return (
+ {/* Title */}
{ onOpenFile(); }} @@ -132,6 +132,8 @@ export const FileMatchContainer = ({
+ + {/* Matches */} {matches.map((match, index) => (
))} + + {/* Show more button */} {isMoreContentButtonVisible && (

- {showAll ? : } - {showAll ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`} + {showAllMatches ? : } + {showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}

)} diff --git a/packages/web/src/app/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/search/components/searchResultsPanel/index.tsx index 128ab9b2..717c335b 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/search/components/searchResultsPanel/index.tsx @@ -1,29 +1,164 @@ 'use client'; import { SearchResultFile } from "@/lib/types"; -import { FileMatchContainer } from "./fileMatchContainer"; +import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; interface SearchResultsPanelProps { fileMatches: SearchResultFile[]; onOpenFileMatch: (fileMatch: SearchResultFile) => void; onMatchIndexChanged: (matchIndex: number) => void; + isLoadMoreButtonVisible: boolean; + onLoadMoreButtonClicked: () => void; } +const ESTIMATED_LINE_HEIGHT_PX = 20; +const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10; +const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30; + export const SearchResultsPanel = ({ fileMatches, onOpenFileMatch, onMatchIndexChanged, + isLoadMoreButtonVisible, + onLoadMoreButtonClicked, }: SearchResultsPanelProps) => { - return fileMatches.map((fileMatch, index) => ( - { - onOpenFileMatch(fileMatch); - }} - onMatchIndexChanged={(matchIndex) => { - onMatchIndexChanged(matchIndex); + const parentRef = useRef(null); + const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false)); + const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1); + + const virtualizer = useVirtualizer({ + count: fileMatches.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => { + const fileMatch = fileMatches[index]; + const showAllMatches = showAllMatchesStates[index]; + + // Quick guesstimation ;) This needs to be quick since the virtualizer will + // run this upfront for all items in the list. + const numCodeCells = fileMatch.ChunkMatches + .filter(match => !match.FileName) + .slice(0, showAllMatches ? fileMatch.ChunkMatches.length : MAX_MATCHES_TO_PREVIEW) + .length; + + const estimatedSize = + numCodeCells * ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL * ESTIMATED_LINE_HEIGHT_PX + + ESTIMATED_MATCH_CONTAINER_HEIGHT_PX; + + return estimatedSize; + }, + measureElement: (element, _entry, instance) => { + // @note : Stutters were appearing when scrolling upwards. The workaround is + // to use the cached height of the element when scrolling up. + // @see : https://github.com/TanStack/virtual/issues/659 + const isCacheDirty = element.hasAttribute("data-cache-dirty"); + element.removeAttribute("data-cache-dirty"); + const direction = instance.scrollDirection; + if (direction === "forward" || direction === null || isCacheDirty) { + return element.scrollHeight; + } else { + const indexKey = Number(element.getAttribute("data-index")); + // Unfortunately, the cache is a private property, so we need to + // hush the TS compiler. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const cacheMeasurement = instance.itemSizeCache.get(indexKey); + return cacheMeasurement; + } + }, + enabled: true, + overscan: 10, + debug: false, + }); + + const onShowAllMatchesButtonClicked = useCallback((index: number) => { + const states = [...showAllMatchesStates]; + states[index] = !states[index]; + setShowAllMatchesStates(states); + setLastShowAllMatchesButtonClickIndex(index); + }, [showAllMatchesStates]); + + // After the "show N more/less matches" button is clicked, the FileMatchContainer's + // size can change considerably. In cases where N > 3 or 4 cells when collapsing, + // a visual artifact can appear where there is a large gap between the now collapsed + // container and the next container. This is because the container's height was not + // re-calculated. To get arround this, we force a re-measure of the element AFTER + // it was re-rendered (hence the useLayoutEffect). + useLayoutEffect(() => { + if (lastShowAllMatchesButtonClickIndex < 0) { + return; + } + + const element = virtualizer.elementsCache.get(lastShowAllMatchesButtonClickIndex); + element?.setAttribute('data-cache-dirty', 'true'); + virtualizer.measureElement(element); + + setLastShowAllMatchesButtonClickIndex(-1); + }, [lastShowAllMatchesButtonClickIndex, virtualizer]); + + // Reset some state when the file matches change. + useEffect(() => { + setShowAllMatchesStates(Array(fileMatches.length).fill(false)); + virtualizer.scrollToIndex(0); + }, [fileMatches, virtualizer]); + + return ( +
- )) + > +
+ {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ { + onOpenFileMatch(fileMatches[virtualRow.index]); + }} + onMatchIndexChanged={(matchIndex) => { + onMatchIndexChanged(matchIndex); + }} + showAllMatches={showAllMatchesStates[virtualRow.index]} + onShowAllMatchesButtonClicked={() => { + onShowAllMatchesButtonClicked(virtualRow.index); + }} + /> +
+ ))} +
+ {isLoadMoreButtonVisible && ( +
+ + Load more results + +
+ )} +
+ ) } \ No newline at end of file diff --git a/packages/web/src/app/search/components/searchResultsPanel/lightweightCodeMirror.tsx b/packages/web/src/app/search/components/searchResultsPanel/lightweightCodeMirror.tsx new file mode 100644 index 00000000..5cc774b4 --- /dev/null +++ b/packages/web/src/app/search/components/searchResultsPanel/lightweightCodeMirror.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { EditorState, Extension, StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; + +interface CodeMirrorProps { + value?: string; + extensions?: Extension[]; + className?: string; +} + +export interface CodeMirrorRef { + editor: HTMLDivElement | null; + state?: EditorState; + view?: EditorView; +} + +/** + * This component provides a lightweight CodeMirror component that has been optimized to + * render quickly in the search results panel. Why not use react-codemirror? For whatever reason, + * react-codemirror issues many StateEffects when first rendering, causing a stuttery scroll + * experience as new cells load. This component is a workaround for that issue and provides + * a minimal react wrapper around CodeMirror that avoids this issue. + */ +const LightweightCodeMirror = forwardRef(({ + value, + extensions, + className, +}, ref) => { + const editor = useRef(null); + const [view, setView] = useState(); + const [state, setState] = useState(); + + useImperativeHandle(ref, () => ({ + editor: editor.current, + state, + view, + }), [editor, state, view]); + + useEffect(() => { + if (!editor.current) { + return; + } + + const state = EditorState.create({ + extensions: [], /* extensions are explicitly left out here */ + doc: value, + }); + setState(state); + + const view = new EditorView({ + state, + parent: editor.current, + }); + setView(view); + + // console.debug(`[CM] Editor created.`); + + return () => { + view.destroy(); + setView(undefined); + setState(undefined); + // console.debug(`[CM] Editor destroyed.`); + } + + }, [value]); + + useEffect(() => { + if (view) { + view.dispatch({ effects: StateEffect.reconfigure.of(extensions ?? []) }); + // console.debug(`[CM] Editor reconfigured.`); + } + }, [extensions, view]); + + return ( +
+ ) +}); + +LightweightCodeMirror.displayName = "LightweightCodeMirror"; + +export { LightweightCodeMirror }; \ No newline at end of file diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx index 09316ac3..5f8563af 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/search/page.tsx @@ -5,14 +5,12 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { SearchQueryParams, SearchResultFile } from "@/lib/types"; import { createPathWithQueryParams } from "@/lib/utils"; import { SymbolIcon } from "@radix-ui/react-icons"; -import { Scrollbar } from "@radix-ui/react-scroll-area"; import { useQuery } from "@tanstack/react-query"; import Image from "next/image"; import { useRouter } from "next/navigation"; @@ -27,7 +25,7 @@ import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { ImperativePanelHandle } from "react-resizable-panels"; -const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 200; +const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; export default function SearchPage() { const router = useRouter(); @@ -212,6 +210,10 @@ const PanelGroup = ({ } }, [selectedFile]); + const onFilterChanged = useCallback((matches: SearchResultFile[]) => { + setFilteredFileMatches(matches); + }, []); + return ( { - setFilteredFileMatches(filteredFileMatches) - }} + onFilterChanged={onFilterChanged} /> {filteredFileMatches.length > 0 ? ( - - { - setSelectedFile(fileMatch); - }} - onMatchIndexChanged={(matchIndex) => { - setSelectedMatchIndex(matchIndex); - }} - /> - {isMoreResultsButtonVisible && ( -
- - Load more results - -
- )} - -
+ { + setSelectedFile(fileMatch); + }} + onMatchIndexChanged={(matchIndex) => { + setSelectedMatchIndex(matchIndex); + }} + isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} + onLoadMoreButtonClicked={onLoadMoreResults} + /> ) : (

No results found

diff --git a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts b/packages/web/src/hooks/useSyntaxHighlightingExtension.ts index af6acd6f..8318e654 100644 --- a/packages/web/src/hooks/useSyntaxHighlightingExtension.ts +++ b/packages/web/src/hooks/useSyntaxHighlightingExtension.ts @@ -21,46 +21,50 @@ export const useSyntaxHighlightingExtension = (language: string, view: EditorVie const extension = useExtensionWithDependency( view ?? null, () => { - switch (language.toLowerCase()) { - case "c": - case "c++": - return cpp(); - case "c#": - return csharp(); - case "json": - return json(); - case "java": - return java(); - case "rust": - return rust(); - case "go": - return go(); - case "sql": - return sql(); - case "php": - return php(); - case "html": - return html(); - case "css": - return css(); - case "jsx": - case "tsx": - case "typescript": - case "javascript": - return javascript({ - jsx: true, - typescript: true, - }); - case "python": - return python(); - case "markdown": - return markdown(); - default: - return []; - } + return getSyntaxHighlightingExtension(language); }, [language] ); return extension; +} + +export const getSyntaxHighlightingExtension = (language: string) => { + switch (language.toLowerCase()) { + case "c": + case "c++": + return cpp(); + case "c#": + return csharp(); + case "json": + return json(); + case "java": + return java(); + case "rust": + return rust(); + case "go": + return go(); + case "sql": + return sql(); + case "php": + return php(); + case "html": + return html(); + case "css": + return css(); + case "jsx": + case "tsx": + case "typescript": + case "javascript": + return javascript({ + jsx: true, + typescript: true, + }); + case "python": + return python(); + case "markdown": + return markdown(); + default: + return []; + } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6ffb2bc5..ddca8307 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1305,11 +1305,23 @@ dependencies: "@tanstack/table-core" "8.20.5" +"@tanstack/react-virtual@^3.10.8": + version "3.10.8" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz#bf4b06f157ed298644a96ab7efc1a2b01ab36e3c" + integrity sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA== + dependencies: + "@tanstack/virtual-core" "3.10.8" + "@tanstack/table-core@8.20.5": version "8.20.5" resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d" integrity sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg== +"@tanstack/virtual-core@3.10.8": + version "3.10.8" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz#975446a667755222f62884c19e5c3c66d959b8b4" + integrity sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA== + "@types/argparse@^2.0.16": version "2.0.16" resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-2.0.16.tgz#3bb7ccd2844b3a8bcd6efbd217f6c0ea06a80d22"