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';
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 { FileMatchContainer } from "./fileMatchContainer";
interface SearchResultsPanelProps {
fileMatches: SearchResultFile[];
@ -16,32 +14,16 @@ export const SearchResultsPanel = ({
onOpenFileMatch,
onMatchIndexChanged,
}: SearchResultsPanelProps) => {
if (fileMatches.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-sm text-muted-foreground">No results found</p>
</div>
);
}
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>
)
return fileMatches.map((fileMatch, index) => (
<FileMatchContainer
key={index}
file={fileMatch}
onOpenFile={() => {
onOpenFileMatch(fileMatch);
}}
onMatchIndexChanged={(matchIndex) => {
onMatchIndexChanged(matchIndex);
}}
/>
))
}

View file

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

View file

@ -8,7 +8,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { cn, createPathWithQueryParams } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { cva } from "class-variance-authority";
import { useRouter } from "next/navigation";
@ -16,6 +16,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { useHotkeys } from 'react-hotkeys-hook'
import { useRef } from "react";
import { SearchQueryParams } from "./search/page";
interface SearchBarProps {
className?: string;
@ -65,7 +66,10 @@ export const SearchBar = ({
});
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 (

View file

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

View file

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