improve UX around how we communicate 'load more results'

This commit is contained in:
bkellam 2024-09-25 23:31:51 -07:00
parent afede30de6
commit 10aa249995
5 changed files with 101 additions and 66 deletions

View file

@ -1,9 +1,7 @@
'use client'; 'use client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Scrollbar } from "@radix-ui/react-scroll-area";
import { FileMatchContainer } from "./fileMatchContainer";
import { SearchResultFile } from "@/lib/types"; import { SearchResultFile } from "@/lib/types";
import { FileMatchContainer } from "./fileMatchContainer";
interface SearchResultsPanelProps { interface SearchResultsPanelProps {
fileMatches: SearchResultFile[]; fileMatches: SearchResultFile[];
@ -16,32 +14,16 @@ export const SearchResultsPanel = ({
onOpenFileMatch, onOpenFileMatch,
onMatchIndexChanged, onMatchIndexChanged,
}: SearchResultsPanelProps) => { }: SearchResultsPanelProps) => {
return fileMatches.map((fileMatch, index) => (
if (fileMatches.length === 0) { <FileMatchContainer
return ( key={index}
<div className="flex flex-col items-center justify-center h-full"> file={fileMatch}
<p className="text-sm text-muted-foreground">No results found</p> onOpenFile={() => {
</div> onOpenFileMatch(fileMatch);
); }}
} onMatchIndexChanged={(matchIndex) => {
onMatchIndexChanged(matchIndex);
return ( }}
<ScrollArea />
className="h-full" ))
>
{fileMatches.map((fileMatch, index) => (
<FileMatchContainer
key={index}
file={fileMatch}
onOpenFile={() => {
onOpenFileMatch(fileMatch);
}}
onMatchIndexChanged={(matchIndex) => {
onMatchIndexChanged(matchIndex);
}}
/>
))}
<Scrollbar orientation="vertical" />
</ScrollArea>
)
} }

View file

@ -12,7 +12,7 @@ import { SymbolIcon } from "@radix-ui/react-icons";
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";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import logoDark from "../../../public/sb_logo_dark.png"; import logoDark from "../../../public/sb_logo_dark.png";
import logoLight from "../../../public/sb_logo_light.png"; import logoLight from "../../../public/sb_logo_light.png";
import { search } from "../api/(client)/client"; import { search } from "../api/(client)/client";
@ -22,14 +22,22 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
import { CodePreviewPanel } from "./components/codePreviewPanel"; import { CodePreviewPanel } from "./components/codePreviewPanel";
import { SearchResultsPanel } from "./components/searchResultsPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel";
import { SearchResultFile } from "@/lib/types"; import { SearchResultFile } from "@/lib/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Scrollbar } from "@radix-ui/react-scroll-area";
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 200;
export enum SearchQueryParams {
query = "query",
maxMatchDisplayCount = "maxMatchDisplayCount",
}
const DEFAULT_NUM_RESULTS = 100;
export default function SearchPage() { export default function SearchPage() {
const router = useRouter(); const router = useRouter();
const searchQuery = useNonEmptyQueryParam("query") ?? ""; const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
const _numResults = parseInt(useNonEmptyQueryParam("numResults") ?? `${DEFAULT_NUM_RESULTS}`); const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`);
const numResults = isNaN(_numResults) ? DEFAULT_NUM_RESULTS : _numResults; const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined); const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined);
@ -37,10 +45,10 @@ export default function SearchPage() {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const { data: searchResponse, isLoading } = useQuery({ const { data: searchResponse, isLoading } = useQuery({
queryKey: ["search", searchQuery, numResults], queryKey: ["search", searchQuery, maxMatchDisplayCount],
queryFn: () => search({ queryFn: () => search({
query: searchQuery, query: searchQuery,
numResults, maxMatchDisplayCount,
}), }),
enabled: searchQuery.length > 0, enabled: searchQuery.length > 0,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@ -93,8 +101,28 @@ export default function SearchPage() {
}, [searchResponse]); }, [searchResponse]);
const isMoreResultsButtonVisible = useMemo(() => { const isMoreResultsButtonVisible = useMemo(() => {
return searchResponse && searchResponse.Result.MatchCount > numResults; return searchResponse && searchResponse.Result.MatchCount > maxMatchDisplayCount;
}, [searchResponse, numResults]); }, [searchResponse, maxMatchDisplayCount]);
const numMatches = useMemo(() => {
// Accumualtes the number of matches across all files
return searchResponse?.Result.Files?.reduce(
(acc, file) =>
acc + file.ChunkMatches.reduce(
(acc, chunk) => acc + chunk.Ranges.length,
0,
),
0,
) ?? 0;
}, [searchResponse]);
const onLoadMoreResults = useCallback(() => {
const url = createPathWithQueryParams('/search',
[SearchQueryParams.query, searchQuery],
[SearchQueryParams.maxMatchDisplayCount, `${maxMatchDisplayCount * 2}`],
)
router.push(url);
}, [maxMatchDisplayCount, router, searchQuery]);
return ( return (
<div className="flex flex-col h-screen overflow-clip"> <div className="flex flex-col h-screen overflow-clip">
@ -129,20 +157,22 @@ export default function SearchPage() {
/> />
</div> </div>
<Separator /> <Separator />
<div className="bg-accent py-1 px-2 flex flex-row items-center justify-between"> <div className="bg-accent py-1 px-2 flex flex-row items-center gap-4">
<p className="text-sm font-medium">Results for: {fileMatches.length} files in {searchDurationMs} ms</p> {
{isMoreResultsButtonVisible && ( isLoading ? (
<p className="text-sm font-medium">Loading...</p>
) : fileMatches.length > 0 ? (
<p className="text-sm font-medium">{`[${searchDurationMs} ms] Displaying ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}</p>
) : (
<p className="text-sm font-medium">No results</p>
)
}
{isMoreResultsButtonVisible && !isLoading && (
<div <div
className="cursor-pointer text-blue-500 text-sm hover:underline" className="cursor-pointer text-blue-500 text-sm hover:underline"
onClick={() => { onClick={onLoadMoreResults}
const url = createPathWithQueryParams('/search',
["query", searchQuery],
["numResults", `${numResults * 2}`],
)
router.push(url);
}}
> >
More results (load more)
</div> </div>
)} )}
</div> </div>
@ -157,16 +187,35 @@ export default function SearchPage() {
<SymbolIcon className="h-6 w-6 animate-spin" /> <SymbolIcon className="h-6 w-6 animate-spin" />
<p className="font-semibold text-center">Searching...</p> <p className="font-semibold text-center">Searching...</p>
</div> </div>
) : fileMatches.length > 0 ? (
<ScrollArea
className="h-full"
>
<SearchResultsPanel
fileMatches={fileMatches}
onOpenFileMatch={(fileMatch) => {
setSelectedFile(fileMatch);
}}
onMatchIndexChanged={(matchIndex) => {
setSelectedMatchIndex(matchIndex);
}}
/>
{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>
) : ( ) : (
<SearchResultsPanel <div className="flex flex-col items-center justify-center h-full">
fileMatches={fileMatches} <p className="text-sm text-muted-foreground">No results found</p>
onOpenFileMatch={(fileMatch) => { </div>
setSelectedFile(fileMatch);
}}
onMatchIndexChanged={(matchIndex) => {
setSelectedMatchIndex(matchIndex);
}}
/>
)} )}
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle={selectedFile !== undefined} /> <ResizableHandle withHandle={selectedFile !== undefined} />

View file

@ -8,7 +8,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn, createPathWithQueryParams } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -16,6 +16,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useRef } from "react"; import { useRef } from "react";
import { SearchQueryParams } from "./search/page";
interface SearchBarProps { interface SearchBarProps {
className?: string; className?: string;
@ -65,7 +66,10 @@ export const SearchBar = ({
}); });
const onSubmit = (values: z.infer<typeof formSchema>) => { const onSubmit = (values: z.infer<typeof formSchema>) => {
router.push(`/search?query=${values.query}&numResults=100`); const url = createPathWithQueryParams('/search',
[SearchQueryParams.query, values.query],
)
router.push(url);
} }
return ( return (

View file

@ -2,7 +2,7 @@ import { z } from "zod";
export const searchRequestSchema = z.object({ export const searchRequestSchema = z.object({
query: z.string(), query: z.string(),
numResults: z.number(), maxMatchDisplayCount: z.number(),
whole: z.boolean().optional(), whole: z.boolean().optional(),
}); });

View file

@ -6,14 +6,14 @@ import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } fro
import { isServiceError } from "../utils"; import { isServiceError } from "../utils";
import { zoektFetch } from "./zoektClient"; import { zoektFetch } from "./zoektClient";
export const search = async ({ query, numResults, whole }: SearchRequest): Promise<SearchResponse | ServiceError> => { export const search = async ({ query, maxMatchDisplayCount, whole }: SearchRequest): Promise<SearchResponse | ServiceError> => {
const body = JSON.stringify({ const body = JSON.stringify({
q: query, q: query,
// @see: https://github.com/TaqlaAI/zoekt/blob/main/api.go#L892 // @see: https://github.com/TaqlaAI/zoekt/blob/main/api.go#L892
opts: { opts: {
NumContextLines: 2, NumContextLines: 2,
ChunkMatches: true, ChunkMatches: true,
MaxMatchDisplayCount: numResults, MaxMatchDisplayCount: maxMatchDisplayCount,
Whole: !!whole, Whole: !!whole,
ShardMaxMatchCount: SHARD_MAX_MATCH_COUNT, ShardMaxMatchCount: SHARD_MAX_MATCH_COUNT,
TotalMaxMatchCount: TOTAL_MAX_MATCH_COUNT, TotalMaxMatchCount: TOTAL_MAX_MATCH_COUNT,
@ -46,7 +46,7 @@ export const getFileSource = async ({ fileName, repository }: FileSourceRequest)
const searchResponse = await search({ const searchResponse = await search({
query: `${escapedFileName} repo:^${escapedRepository}$`, query: `${escapedFileName} repo:^${escapedRepository}$`,
numResults: 1, maxMatchDisplayCount: 1,
whole: true, whole: true,
}); });