diff --git a/CHANGELOG.md b/CHANGELOG.md index 54979a81..94e4e529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Changed the filter panel to embed the filter selection state in the query params. [#276](https://github.com/sourcebot-dev/sourcebot/pull/276) + ## [3.1.0] - 2025-04-25 ### Added diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx index c3ce800b..ffb2b496 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -1,13 +1,14 @@ 'use client'; +import { FileIcon } from "@/components/ui/fileIcon"; import { Repository, SearchResultFile } from "@/lib/types"; import { cn, getRepoCodeHostInfo } from "@/lib/utils"; -import { SetStateAction, useCallback, useEffect, useState } from "react"; +import { LaptopIcon } from "@radix-ui/react-icons"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo } from "react"; import { Entry } from "./entry"; import { Filter } from "./filter"; -import Image from "next/image"; -import { LaptopIcon } from "@radix-ui/react-icons"; -import { FileIcon } from "@/components/ui/fileIcon"; interface FilePanelProps { matches: SearchResultFile[]; @@ -15,16 +16,26 @@ interface FilePanelProps { repoMetadata: Record; } +const LANGUAGES_QUERY_PARAM = "langs"; +const REPOS_QUERY_PARAM = "repos"; + export const FilterPanel = ({ matches, onFilterChanged, repoMetadata, }: FilePanelProps) => { - const [repos, setRepos] = useState>({}); - const [languages, setLanguages] = useState>({}); - - useEffect(() => { - const _repos = aggregateMatches( + const router = useRouter(); + const searchParams = useSearchParams(); + + // Helper to parse query params into sets + const getSelectedFromQuery = (param: string) => { + const value = searchParams.get(param); + return value ? new Set(value.split(',')) : new Set(); + }; + + const repos = useMemo(() => { + const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); + return aggregateMatches( "Repository", matches, (key) => { @@ -44,17 +55,16 @@ export const FilterPanel = ({ key, displayName: info?.displayName ?? key, count: 0, - isSelected: false, + isSelected: selectedRepos.has(key), Icon, }; } - ); - - setRepos(_repos); - }, [matches, repoMetadata, setRepos]); + ) + }, [searchParams]); - useEffect(() => { - const _languages = aggregateMatches( + const languages = useMemo(() => { + const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); + return aggregateMatches( "Language", matches, (key) => { @@ -66,40 +76,18 @@ export const FilterPanel = ({ key, displayName: key, count: 0, - isSelected: false, + isSelected: selectedLanguages.has(key), Icon: Icon, } satisfies Entry; } - ) - - setLanguages(_languages); - }, [matches, setLanguages]); - - const onEntryClicked = useCallback(( - key: string, - setter: (value: SetStateAction>) => void, - ) => { - setter((values) => ({ - ...values, - [key]: { - ...values[key], - isSelected: !values[key].isSelected, - }, - })); - }, []); - - useEffect(() => { - const selectedRepos = new Set( - Object.entries(repos) - .filter(([_, { isSelected }]) => isSelected) - .map(([key]) => key) ); + }, [searchParams]); - const selectedLanguages = new Set( - Object.entries(languages) - .filter(([_, { isSelected }]) => isSelected) - .map(([key]) => key) - ); + // Calls `onFilterChanged` with the filtered list of matches + // whenever the filter state changes. + useEffect(() => { + const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected)); + const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected)); const filteredMatches = matches.filter((match) => ( @@ -107,26 +95,57 @@ export const FilterPanel = ({ (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) ) ); - onFilterChanged(filteredMatches); - }, [matches, repos, languages, onFilterChanged]); - const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length; - const numLanguages = Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length; + }, [matches, repos, languages, onFilterChanged, searchParams, router]); + + const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]); + const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]); + return (
onEntryClicked(key, setRepos)} + onEntryClicked={(key) => { + const newRepos = { ...repos }; + newRepos[key].isSelected = !newRepos[key].isSelected; + const selectedRepos = Object.keys(newRepos).filter((key) => newRepos[key].isSelected); + const newParams = new URLSearchParams(searchParams.toString()); + + if (selectedRepos.length > 0) { + newParams.set(REPOS_QUERY_PARAM, selectedRepos.join(',')); + } else { + newParams.delete(REPOS_QUERY_PARAM); + } + + if (newParams.toString() !== searchParams.toString()) { + router.replace(`?${newParams.toString()}`, { scroll: false }); + } + }} className="max-h-[50%]" /> onEntryClicked(key, setLanguages)} + onEntryClicked={(key) => { + const newLanguages = { ...languages }; + newLanguages[key].isSelected = !newLanguages[key].isSelected; + const selectedLanguages = Object.keys(newLanguages).filter((key) => newLanguages[key].isSelected); + const newParams = new URLSearchParams(searchParams.toString()); + + if (selectedLanguages.length > 0) { + newParams.set(LANGUAGES_QUERY_PARAM, selectedLanguages.join(',')); + } else { + newParams.delete(LANGUAGES_QUERY_PARAM); + } + + if (newParams.toString() !== searchParams.toString()) { + router.replace(`?${newParams.toString()}`, { scroll: false }); + } + }} className="overflow-auto" />
diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index 134b6b02..aab869fa 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -47,7 +47,7 @@ const SearchPageInternal = () => { const domain = useDomain(); const { toast } = useToast(); - const { data: searchResponse, isLoading, error } = useQuery({ + const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ queryKey: ["search", searchQuery, maxMatchDisplayCount], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, @@ -91,7 +91,7 @@ const SearchPageInternal = () => { // repository metadata (like host type, repo name, etc.) // Convert this into a map of repo name to repo metadata // for easy lookup. - const { data: repoMetadata } = useQuery({ + const { data: repoMetadata, isLoading: isRepoMetadataLoading } = useQuery({ queryKey: ["repos"], queryFn: () => getRepos(domain), select: (data): Record => @@ -194,7 +194,7 @@ const SearchPageInternal = () => { - {isLoading ? ( + {(isSearchLoading || isRepoMetadataLoading) ? (

Searching...

diff --git a/packages/web/src/hooks/useNonEmptyQueryParam.ts b/packages/web/src/hooks/useNonEmptyQueryParam.ts index d3a20417..0f21ebf6 100644 --- a/packages/web/src/hooks/useNonEmptyQueryParam.ts +++ b/packages/web/src/hooks/useNonEmptyQueryParam.ts @@ -17,11 +17,11 @@ import { useMemo } from "react"; */ export const useNonEmptyQueryParam = (param: string) => { const searchParams = useSearchParams(); - const inviteId = useMemo(() => { + const paramValue = useMemo(() => { return getSearchParam(param, searchParams); }, [param, searchParams]); - return inviteId; + return paramValue; }; /**