mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Improve rendering performance of search results (#52)
This commit is contained in:
parent
8a619b7145
commit
e913b22324
14 changed files with 362 additions and 151 deletions
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
|
@ -2,5 +2,10 @@
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.json": "jsonc",
|
"*.json": "jsonc",
|
||||||
"index.json": "json"
|
"index.json": "json"
|
||||||
}
|
},
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{
|
||||||
|
"pattern": "./packages/*/"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed issue with GitLab sub-projects not being included recursively. ([#54](https://github.com/sourcebot-dev/sourcebot/pull/54))
|
- 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
|
## [2.1.1] - 2024-10-25
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"@tanstack/react-query": "^5.53.3",
|
"@tanstack/react-query": "^5.53.3",
|
||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@uiw/react-codemirror": "^4.23.0",
|
"@uiw/react-codemirror": "^4.23.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -59,5 +59,4 @@ export const CodePreviewPanel = ({
|
||||||
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ export const FilterPanel = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
onFilterChanged(filteredMatches);
|
onFilterChanged(filteredMatches);
|
||||||
}, [matches, repos, languages]);
|
}, [matches, repos, languages, onFilterChanged]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 flex flex-col gap-3">
|
<div className="p-3 flex flex-col gap-3">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
|
import { getSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
||||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
|
||||||
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
|
|
||||||
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
||||||
import { SearchResultRange } from "@/lib/types";
|
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 { useMemo, useRef } from "react";
|
||||||
|
import { LightweightCodeMirror, CodeMirrorRef } from "./lightweightCodeMirror";
|
||||||
|
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
|
||||||
|
|
||||||
const markDecoration = Decoration.mark({
|
const markDecoration = Decoration.mark({
|
||||||
class: "cm-searchMatch-selected"
|
class: "cm-searchMatch-selected"
|
||||||
|
|
@ -25,13 +28,22 @@ export const CodePreview = ({
|
||||||
ranges,
|
ranges,
|
||||||
lineOffset,
|
lineOffset,
|
||||||
}: CodePreviewProps) => {
|
}: CodePreviewProps) => {
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
const editorRef = useRef<CodeMirrorRef>(null);
|
||||||
const { theme } = useThemeNormalized();
|
const { theme } = useThemeNormalized();
|
||||||
|
|
||||||
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
|
const extensions = useMemo(() => {
|
||||||
|
|
||||||
const rangeHighlighting = useExtensionWithDependency(editorRef.current?.view ?? null, () => {
|
|
||||||
return [
|
return [
|
||||||
|
EditorView.editable.of(false),
|
||||||
|
...(theme === 'dark' ? [
|
||||||
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
|
oneDarkTheme,
|
||||||
|
] : [
|
||||||
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
|
defaultLightThemeOption,
|
||||||
|
]),
|
||||||
|
lineNumbers(),
|
||||||
|
lineOffsetExtension(lineOffset),
|
||||||
|
getSyntaxHighlightingExtension(language),
|
||||||
StateField.define<DecorationSet>({
|
StateField.define<DecorationSet>({
|
||||||
create(editorState: EditorState) {
|
create(editorState: EditorState) {
|
||||||
const document = editorState.doc;
|
const document = editorState.doc;
|
||||||
|
|
@ -61,7 +73,8 @@ export const CodePreview = ({
|
||||||
const from = document.line(startLine).from + Start.Column - 1;
|
const from = document.line(startLine).from + Start.Column - 1;
|
||||||
const to = document.line(endLine).from + End.Column - 1;
|
const to = document.line(endLine).from + End.Column - 1;
|
||||||
return markDecoration.range(from, to);
|
return markDecoration.range(from, to);
|
||||||
});
|
})
|
||||||
|
.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
return Decoration.set(decorations);
|
return Decoration.set(decorations);
|
||||||
},
|
},
|
||||||
|
|
@ -70,56 +83,15 @@ export const CodePreview = ({
|
||||||
},
|
},
|
||||||
provide: (field) => EditorView.decorations.from(field),
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
}),
|
}),
|
||||||
];
|
]
|
||||||
}, [ranges, lineOffset]);
|
}, [language, lineOffset, ranges, theme]);
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
return [
|
|
||||||
syntaxHighlighting,
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
lineOffsetExtension(lineOffset),
|
|
||||||
rangeHighlighting,
|
|
||||||
];
|
|
||||||
}, [syntaxHighlighting, lineOffset, rangeHighlighting]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeMirror
|
<LightweightCodeMirror
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
readOnly={true}
|
|
||||||
editable={false}
|
|
||||||
value={content}
|
value={content}
|
||||||
theme={theme === "dark" ? "dark" : "light"}
|
|
||||||
basicSetup={{
|
|
||||||
lineNumbers: true,
|
|
||||||
syntaxHighlighting: true,
|
|
||||||
|
|
||||||
// Disable all this other stuff...
|
|
||||||
... {
|
|
||||||
foldGutter: false,
|
|
||||||
highlightActiveLineGutter: false,
|
|
||||||
highlightSpecialChars: false,
|
|
||||||
history: false,
|
|
||||||
drawSelection: false,
|
|
||||||
dropCursor: false,
|
|
||||||
allowMultipleSelections: false,
|
|
||||||
indentOnInput: false,
|
|
||||||
bracketMatching: false,
|
|
||||||
closeBrackets: false,
|
|
||||||
autocompletion: false,
|
|
||||||
rectangularSelection: false,
|
|
||||||
crosshairCursor: false,
|
|
||||||
highlightActiveLine: false,
|
|
||||||
highlightSelectionMatches: false,
|
|
||||||
closeBracketsKeymap: false,
|
|
||||||
defaultKeymap: false,
|
|
||||||
searchKeymap: false,
|
|
||||||
historyKeymap: false,
|
|
||||||
foldKeymap: false,
|
|
||||||
completionKeymap: false,
|
|
||||||
lintKeymap: false,
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ export const FileMatch = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="cursor-pointer p-1 focus:ring-inset focus:ring-4 bg-white dark:bg-[#282c34]"
|
className="cursor-pointer focus:ring-inset focus:ring-4 bg-white dark:bg-[#282c34]"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Enter") {
|
if (e.key !== "Enter") {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getRepoCodeHostInfo } from "@/lib/utils";
|
import { getRepoCodeHostInfo } from "@/lib/utils";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
|
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
@ -9,21 +9,24 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { SearchResultFile } from "@/lib/types";
|
import { SearchResultFile } from "@/lib/types";
|
||||||
import { FileMatch } from "./fileMatch";
|
import { FileMatch } from "./fileMatch";
|
||||||
|
|
||||||
const MAX_MATCHES_TO_PREVIEW = 3;
|
export const MAX_MATCHES_TO_PREVIEW = 3;
|
||||||
|
|
||||||
interface FileMatchContainerProps {
|
interface FileMatchContainerProps {
|
||||||
file: SearchResultFile;
|
file: SearchResultFile;
|
||||||
onOpenFile: () => void;
|
onOpenFile: () => void;
|
||||||
onMatchIndexChanged: (matchIndex: number) => void;
|
onMatchIndexChanged: (matchIndex: number) => void;
|
||||||
|
showAllMatches: boolean;
|
||||||
|
onShowAllMatchesButtonClicked: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileMatchContainer = ({
|
export const FileMatchContainer = ({
|
||||||
file,
|
file,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onMatchIndexChanged,
|
onMatchIndexChanged,
|
||||||
|
showAllMatches,
|
||||||
|
onShowAllMatchesButtonClicked,
|
||||||
}: FileMatchContainerProps) => {
|
}: FileMatchContainerProps) => {
|
||||||
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const matchCount = useMemo(() => {
|
const matchCount = useMemo(() => {
|
||||||
return file.ChunkMatches.length;
|
return file.ChunkMatches.length;
|
||||||
}, [file]);
|
}, [file]);
|
||||||
|
|
@ -33,12 +36,12 @@ export const FileMatchContainer = ({
|
||||||
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
|
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!showAll) {
|
if (!showAllMatches) {
|
||||||
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
|
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortedMatches;
|
return sortedMatches;
|
||||||
}, [file, showAll]);
|
}, [file, showAllMatches]);
|
||||||
|
|
||||||
const fileNameRange = useMemo(() => {
|
const fileNameRange = useMemo(() => {
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
|
@ -79,10 +82,6 @@ export const FileMatchContainer = ({
|
||||||
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
||||||
}, [matchCount]);
|
}, [matchCount]);
|
||||||
|
|
||||||
const onShowMoreMatches = useCallback(() => {
|
|
||||||
setShowAll(!showAll);
|
|
||||||
}, [showAll]);
|
|
||||||
|
|
||||||
const onOpenMatch = useCallback((index: number) => {
|
const onOpenMatch = useCallback((index: number) => {
|
||||||
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
||||||
return acc + match.Ranges.length;
|
return acc + match.Ranges.length;
|
||||||
|
|
@ -94,8 +93,9 @@ export const FileMatchContainer = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Title */}
|
||||||
<div
|
<div
|
||||||
className="sticky top-0 bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between cursor-pointer z-10"
|
className="top-0 bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenFile();
|
onOpenFile();
|
||||||
}}
|
}}
|
||||||
|
|
@ -132,6 +132,8 @@ export const FileMatchContainer = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Matches */}
|
||||||
{matches.map((match, index) => (
|
{matches.map((match, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -148,6 +150,8 @@ export const FileMatchContainer = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Show more button */}
|
||||||
{isMoreContentButtonVisible && (
|
{isMoreContentButtonVisible && (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -156,15 +160,15 @@ export const FileMatchContainer = ({
|
||||||
if (e.key !== "Enter") {
|
if (e.key !== "Enter") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onShowMoreMatches();
|
onShowAllMatchesButtonClicked();
|
||||||
}}
|
}}
|
||||||
onClick={onShowMoreMatches}
|
onClick={onShowAllMatchesButtonClicked}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
|
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
|
||||||
>
|
>
|
||||||
{showAll ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
|
{showAllMatches ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
|
||||||
{showAll ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
|
{showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,164 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { SearchResultFile } from "@/lib/types";
|
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 {
|
interface SearchResultsPanelProps {
|
||||||
fileMatches: SearchResultFile[];
|
fileMatches: SearchResultFile[];
|
||||||
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
|
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
|
||||||
onMatchIndexChanged: (matchIndex: number) => 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 = ({
|
export const SearchResultsPanel = ({
|
||||||
fileMatches,
|
fileMatches,
|
||||||
onOpenFileMatch,
|
onOpenFileMatch,
|
||||||
onMatchIndexChanged,
|
onMatchIndexChanged,
|
||||||
|
isLoadMoreButtonVisible,
|
||||||
|
onLoadMoreButtonClicked,
|
||||||
}: SearchResultsPanelProps) => {
|
}: SearchResultsPanelProps) => {
|
||||||
return fileMatches.map((fileMatch, index) => (
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
<FileMatchContainer
|
const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false));
|
||||||
key={index}
|
const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1);
|
||||||
file={fileMatch}
|
|
||||||
onOpenFile={() => {
|
const virtualizer = useVirtualizer({
|
||||||
onOpenFileMatch(fileMatch);
|
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 (
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflowY: 'auto',
|
||||||
|
contain: 'strict',
|
||||||
}}
|
}}
|
||||||
onMatchIndexChanged={(matchIndex) => {
|
>
|
||||||
onMatchIndexChanged(matchIndex);
|
<div
|
||||||
}}
|
style={{
|
||||||
/>
|
height: virtualizer.getTotalSize(),
|
||||||
))
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileMatchContainer
|
||||||
|
file={fileMatches[virtualRow.index]}
|
||||||
|
onOpenFile={() => {
|
||||||
|
onOpenFileMatch(fileMatches[virtualRow.index]);
|
||||||
|
}}
|
||||||
|
onMatchIndexChanged={(matchIndex) => {
|
||||||
|
onMatchIndexChanged(matchIndex);
|
||||||
|
}}
|
||||||
|
showAllMatches={showAllMatchesStates[virtualRow.index]}
|
||||||
|
onShowAllMatchesButtonClicked={() => {
|
||||||
|
onShowAllMatchesButtonClicked(virtualRow.index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isLoadMoreButtonVisible && (
|
||||||
|
<div className="p-3">
|
||||||
|
<span
|
||||||
|
className="cursor-pointer text-blue-500 hover:underline"
|
||||||
|
onClick={onLoadMoreButtonClicked}
|
||||||
|
>
|
||||||
|
Load more results
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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<CodeMirrorRef, CodeMirrorProps>(({
|
||||||
|
value,
|
||||||
|
extensions,
|
||||||
|
className,
|
||||||
|
}, ref) => {
|
||||||
|
const editor = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [view, setView] = useState<EditorView>();
|
||||||
|
const [state, setState] = useState<EditorState>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
ref={editor}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
LightweightCodeMirror.displayName = "LightweightCodeMirror";
|
||||||
|
|
||||||
|
export { LightweightCodeMirror };
|
||||||
|
|
@ -5,14 +5,12 @@ import {
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
import { SearchQueryParams, SearchResultFile } from "@/lib/types";
|
import { SearchQueryParams, SearchResultFile } from "@/lib/types";
|
||||||
import { createPathWithQueryParams } from "@/lib/utils";
|
import { createPathWithQueryParams } from "@/lib/utils";
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -27,7 +25,7 @@ import { FilterPanel } from "./components/filterPanel";
|
||||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 200;
|
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -212,6 +210,10 @@ const PanelGroup = ({
|
||||||
}
|
}
|
||||||
}, [selectedFile]);
|
}, [selectedFile]);
|
||||||
|
|
||||||
|
const onFilterChanged = useCallback((matches: SearchResultFile[]) => {
|
||||||
|
setFilteredFileMatches(matches);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
|
|
@ -227,9 +229,7 @@ const PanelGroup = ({
|
||||||
>
|
>
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
matches={fileMatches}
|
matches={fileMatches}
|
||||||
onFilterChanged={(filteredFileMatches) => {
|
onFilterChanged={onFilterChanged}
|
||||||
setFilteredFileMatches(filteredFileMatches)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle
|
<ResizableHandle
|
||||||
|
|
@ -243,30 +243,17 @@ const PanelGroup = ({
|
||||||
order={2}
|
order={2}
|
||||||
>
|
>
|
||||||
{filteredFileMatches.length > 0 ? (
|
{filteredFileMatches.length > 0 ? (
|
||||||
<ScrollArea
|
<SearchResultsPanel
|
||||||
className="h-full"
|
fileMatches={filteredFileMatches}
|
||||||
>
|
onOpenFileMatch={(fileMatch) => {
|
||||||
<SearchResultsPanel
|
setSelectedFile(fileMatch);
|
||||||
fileMatches={filteredFileMatches}
|
}}
|
||||||
onOpenFileMatch={(fileMatch) => {
|
onMatchIndexChanged={(matchIndex) => {
|
||||||
setSelectedFile(fileMatch);
|
setSelectedMatchIndex(matchIndex);
|
||||||
}}
|
}}
|
||||||
onMatchIndexChanged={(matchIndex) => {
|
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
||||||
setSelectedMatchIndex(matchIndex);
|
onLoadMoreButtonClicked={onLoadMoreResults}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
{isMoreResultsButtonVisible && (
|
|
||||||
<div className="p-3">
|
|
||||||
<span
|
|
||||||
className="cursor-pointer text-blue-500 hover:underline"
|
|
||||||
onClick={onLoadMoreResults}
|
|
||||||
>
|
|
||||||
Load more results
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Scrollbar orientation="vertical" />
|
|
||||||
</ScrollArea>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">No results found</p>
|
<p className="text-sm text-muted-foreground">No results found</p>
|
||||||
|
|
|
||||||
|
|
@ -21,46 +21,50 @@ export const useSyntaxHighlightingExtension = (language: string, view: EditorVie
|
||||||
const extension = useExtensionWithDependency(
|
const extension = useExtensionWithDependency(
|
||||||
view ?? null,
|
view ?? null,
|
||||||
() => {
|
() => {
|
||||||
switch (language.toLowerCase()) {
|
return getSyntaxHighlightingExtension(language);
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[language]
|
[language]
|
||||||
);
|
);
|
||||||
|
|
||||||
return extension;
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
12
yarn.lock
12
yarn.lock
|
|
@ -1305,11 +1305,23 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/table-core" "8.20.5"
|
"@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":
|
"@tanstack/table-core@8.20.5":
|
||||||
version "8.20.5"
|
version "8.20.5"
|
||||||
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d"
|
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d"
|
||||||
integrity sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==
|
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":
|
"@types/argparse@^2.0.16":
|
||||||
version "2.0.16"
|
version "2.0.16"
|
||||||
resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-2.0.16.tgz#3bb7ccd2844b3a8bcd6efbd217f6c0ea06a80d22"
|
resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-2.0.16.tgz#3bb7ccd2844b3a8bcd6efbd217f6c0ea06a80d22"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue