'use client'; import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useSearchHistory } from "@/hooks/useSearchHistory"; import { SearchQueryParams } from "@/lib/types"; import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils"; import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import { RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search/types"; import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; import { useFilteredMatches } from "./components/filterPanel/useFilterMatches"; import { Button } from "@/components/ui/button"; import { ImperativePanelHandle } from "react-resizable-panels"; import { AlertTriangleIcon, BugIcon, FilterIcon } from "lucide-react"; import { useHotkeys } from "react-hotkeys-hook"; import { useLocalStorage } from "@uidotdev/usehooks"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { SearchBar } from "../components/searchBar"; import { CodeSnippet } from "@/app/components/codeSnippet"; import { CopyIconButton } from "../components/copyIconButton"; const DEFAULT_MAX_MATCH_COUNT = 500; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params // in the top level page. // @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout return ( ) } const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); const { toast } = useToast(); // Encodes the number of matches to return in the search response. const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`); const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount; const { data: searchResponse, isPending: isSearchPending, isFetching: isFetching, error } = useQuery({ queryKey: ["search", searchQuery, maxMatchCount], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, matches: maxMatchCount, contextLines: 3, whole: false, }, domain)), "client.search"), select: ({ data, durationMs }) => ({ ...data, totalClientSearchDurationMs: durationMs, }), enabled: searchQuery.length > 0, refetchOnWindowFocus: false, retry: false, staleTime: 0, }); useEffect(() => { if (error) { toast({ description: `❌ Search failed. Reason: ${error.message}`, }); } }, [error, toast]); // Write the query to the search history useEffect(() => { if (searchQuery.length === 0) { return; } const now = new Date().toUTCString(); setSearchHistory((searchHistory) => [ { query: searchQuery, date: now, }, ...searchHistory.filter(search => search.query !== searchQuery), ]) }, [searchQuery, setSearchHistory]); useEffect(() => { if (!searchResponse) { return; } const fileLanguages = searchResponse.files?.map(file => file.language) || []; captureEvent("search_finished", { durationMs: searchResponse.totalClientSearchDurationMs, fileCount: searchResponse.stats.fileCount, matchCount: searchResponse.stats.totalMatchCount, actualMatchCount: searchResponse.stats.actualMatchCount, filesSkipped: searchResponse.stats.filesSkipped, contentBytesLoaded: searchResponse.stats.contentBytesLoaded, indexBytesLoaded: searchResponse.stats.indexBytesLoaded, crashes: searchResponse.stats.crashes, shardFilesConsidered: searchResponse.stats.shardFilesConsidered, filesConsidered: searchResponse.stats.filesConsidered, filesLoaded: searchResponse.stats.filesLoaded, shardsScanned: searchResponse.stats.shardsScanned, shardsSkipped: searchResponse.stats.shardsSkipped, shardsSkippedFilter: searchResponse.stats.shardsSkippedFilter, ngramMatches: searchResponse.stats.ngramMatches, ngramLookups: searchResponse.stats.ngramLookups, wait: searchResponse.stats.wait, matchTreeConstruction: searchResponse.stats.matchTreeConstruction, matchTreeSearch: searchResponse.stats.matchTreeSearch, regexpsConsidered: searchResponse.stats.regexpsConsidered, flushReason: searchResponse.stats.flushReason, fileLanguages, }); }, [captureEvent, searchQuery, searchResponse]); const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], [SearchQueryParams.matches, `${maxMatchCount * 2}`], ) router.push(url); }, [maxMatchCount, router, searchQuery, domain]); return (
{/* TopBar */} {(isSearchPending || isFetching) ? (

Searching...

) : error ? (

Failed to search

{error.message}

) : ( )}
); } interface PanelGroupProps { fileMatches: SearchResultFile[]; isMoreResultsButtonVisible?: boolean; onLoadMoreResults: () => void; isBranchFilteringEnabled: boolean; repoInfo: RepositoryInfo[]; searchDurationMs: number; numMatches: number; searchStats?: SearchStats; } const PanelGroup = ({ fileMatches, isMoreResultsButtonVisible, onLoadMoreResults, isBranchFilteringEnabled, repoInfo: _repoInfo, searchDurationMs: _searchDurationMs, numMatches, searchStats, }: PanelGroupProps) => { const [previewedFile, setPreviewedFile] = useState(undefined); const filteredFileMatches = useFilteredMatches(fileMatches); const filterPanelRef = useRef(null); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false); useHotkeys("mod+b", () => { if (isFilterPanelCollapsed) { filterPanelRef.current?.expand(); } else { filterPanelRef.current?.collapse(); } }, { enableOnFormTags: true, enableOnContentEditable: true, description: "Toggle filter panel", }); const searchDurationMs = useMemo(() => { return Math.round(_searchDurationMs); }, [_searchDurationMs]); const repoInfo = useMemo(() => { return _repoInfo.reduce((acc, repo) => { acc[repo.id] = repo; return acc; }, {} as Record); }, [_repoInfo]); return ( {/* ~~ Filter panel ~~ */} setIsFilterPanelCollapsed(true)} onExpand={() => setIsFilterPanelCollapsed(false)} > {isFilterPanelCollapsed && (
Open filter panel
)} {/* ~~ Search results ~~ */}

Search stats for nerds

{ navigator.clipboard.writeText(JSON.stringify(searchStats, null, 2)); return true; }} className="ml-auto" />
{JSON.stringify(searchStats, null, 2)}
{ fileMatches.length > 0 ? (

{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}

) : (

No results

) } {isMoreResultsButtonVisible && (
(load more)
)}
{filteredFileMatches.length > 0 ? ( { setSelectedMatchIndex(matchIndex ?? 0); setPreviewedFile(fileMatch); }} isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} onLoadMoreButtonClicked={onLoadMoreResults} isBranchFilteringEnabled={isBranchFilteringEnabled} repoInfo={repoInfo} /> ) : (

No results found

)}
{previewedFile && ( <> {/* ~~ Code preview ~~ */} setPreviewedFile(undefined)} > setPreviewedFile(undefined)} selectedMatchIndex={selectedMatchIndex} onSelectedMatchIndexChange={setSelectedMatchIndex} /> )}
) }