Skip to content

Commit ceb8b3a

Browse files
Embed filter selection in query params (#276)
1 parent 78ec512 commit ceb8b3a

File tree

4 files changed

+78
-56
lines changed

4 files changed

+78
-56
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- Changed the filter panel to embed the filter selection state in the query params. [#276](https://github.com/sourcebot-dev/sourcebot/pull/276)
12+
1013
## [3.1.0] - 2025-04-25
1114

1215
### Added

packages/web/src/app/[domain]/search/components/filterPanel/index.tsx

Lines changed: 70 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,41 @@
11
'use client';
22

3+
import { FileIcon } from "@/components/ui/fileIcon";
34
import { Repository, SearchResultFile } from "@/lib/types";
45
import { cn, getRepoCodeHostInfo } from "@/lib/utils";
5-
import { SetStateAction, useCallback, useEffect, useState } from "react";
6+
import { LaptopIcon } from "@radix-ui/react-icons";
7+
import Image from "next/image";
8+
import { useRouter, useSearchParams } from "next/navigation";
9+
import { useEffect, useMemo } from "react";
610
import { Entry } from "./entry";
711
import { Filter } from "./filter";
8-
import Image from "next/image";
9-
import { LaptopIcon } from "@radix-ui/react-icons";
10-
import { FileIcon } from "@/components/ui/fileIcon";
1112

1213
interface FilePanelProps {
1314
matches: SearchResultFile[];
1415
onFilterChanged: (filteredMatches: SearchResultFile[]) => void,
1516
repoMetadata: Record<string, Repository>;
1617
}
1718

19+
const LANGUAGES_QUERY_PARAM = "langs";
20+
const REPOS_QUERY_PARAM = "repos";
21+
1822
export const FilterPanel = ({
1923
matches,
2024
onFilterChanged,
2125
repoMetadata,
2226
}: FilePanelProps) => {
23-
const [repos, setRepos] = useState<Record<string, Entry>>({});
24-
const [languages, setLanguages] = useState<Record<string, Entry>>({});
25-
26-
useEffect(() => {
27-
const _repos = aggregateMatches(
27+
const router = useRouter();
28+
const searchParams = useSearchParams();
29+
30+
// Helper to parse query params into sets
31+
const getSelectedFromQuery = (param: string) => {
32+
const value = searchParams.get(param);
33+
return value ? new Set(value.split(',')) : new Set();
34+
};
35+
36+
const repos = useMemo(() => {
37+
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
38+
return aggregateMatches(
2839
"Repository",
2940
matches,
3041
(key) => {
@@ -44,17 +55,16 @@ export const FilterPanel = ({
4455
key,
4556
displayName: info?.displayName ?? key,
4657
count: 0,
47-
isSelected: false,
58+
isSelected: selectedRepos.has(key),
4859
Icon,
4960
};
5061
}
51-
);
52-
53-
setRepos(_repos);
54-
}, [matches, repoMetadata, setRepos]);
62+
)
63+
}, [searchParams]);
5564

56-
useEffect(() => {
57-
const _languages = aggregateMatches(
65+
const languages = useMemo(() => {
66+
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
67+
return aggregateMatches(
5868
"Language",
5969
matches,
6070
(key) => {
@@ -66,67 +76,76 @@ export const FilterPanel = ({
6676
key,
6777
displayName: key,
6878
count: 0,
69-
isSelected: false,
79+
isSelected: selectedLanguages.has(key),
7080
Icon: Icon,
7181
} satisfies Entry;
7282
}
73-
)
74-
75-
setLanguages(_languages);
76-
}, [matches, setLanguages]);
77-
78-
const onEntryClicked = useCallback((
79-
key: string,
80-
setter: (value: SetStateAction<Record<string, Entry>>) => void,
81-
) => {
82-
setter((values) => ({
83-
...values,
84-
[key]: {
85-
...values[key],
86-
isSelected: !values[key].isSelected,
87-
},
88-
}));
89-
}, []);
90-
91-
useEffect(() => {
92-
const selectedRepos = new Set(
93-
Object.entries(repos)
94-
.filter(([_, { isSelected }]) => isSelected)
95-
.map(([key]) => key)
9683
);
84+
}, [searchParams]);
9785

98-
const selectedLanguages = new Set(
99-
Object.entries(languages)
100-
.filter(([_, { isSelected }]) => isSelected)
101-
.map(([key]) => key)
102-
);
86+
// Calls `onFilterChanged` with the filtered list of matches
87+
// whenever the filter state changes.
88+
useEffect(() => {
89+
const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected));
90+
const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected));
10391

10492
const filteredMatches = matches.filter((match) =>
10593
(
10694
(selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) &&
10795
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language))
10896
)
10997
);
110-
11198
onFilterChanged(filteredMatches);
112-
}, [matches, repos, languages, onFilterChanged]);
11399

114-
const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length;
115-
const numLanguages = Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length;
100+
}, [matches, repos, languages, onFilterChanged, searchParams, router]);
101+
102+
const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]);
103+
const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]);
104+
116105
return (
117106
<div className="p-3 flex flex-col gap-3 h-full">
118107
<Filter
119108
title="Filter By Repository"
120109
searchPlaceholder={`Filter ${numRepos} repositories`}
121110
entries={Object.values(repos)}
122-
onEntryClicked={(key) => onEntryClicked(key, setRepos)}
111+
onEntryClicked={(key) => {
112+
const newRepos = { ...repos };
113+
newRepos[key].isSelected = !newRepos[key].isSelected;
114+
const selectedRepos = Object.keys(newRepos).filter((key) => newRepos[key].isSelected);
115+
const newParams = new URLSearchParams(searchParams.toString());
116+
117+
if (selectedRepos.length > 0) {
118+
newParams.set(REPOS_QUERY_PARAM, selectedRepos.join(','));
119+
} else {
120+
newParams.delete(REPOS_QUERY_PARAM);
121+
}
122+
123+
if (newParams.toString() !== searchParams.toString()) {
124+
router.replace(`?${newParams.toString()}`, { scroll: false });
125+
}
126+
}}
123127
className="max-h-[50%]"
124128
/>
125129
<Filter
126130
title="Filter By Language"
127131
searchPlaceholder={`Filter ${numLanguages} languages`}
128132
entries={Object.values(languages)}
129-
onEntryClicked={(key) => onEntryClicked(key, setLanguages)}
133+
onEntryClicked={(key) => {
134+
const newLanguages = { ...languages };
135+
newLanguages[key].isSelected = !newLanguages[key].isSelected;
136+
const selectedLanguages = Object.keys(newLanguages).filter((key) => newLanguages[key].isSelected);
137+
const newParams = new URLSearchParams(searchParams.toString());
138+
139+
if (selectedLanguages.length > 0) {
140+
newParams.set(LANGUAGES_QUERY_PARAM, selectedLanguages.join(','));
141+
} else {
142+
newParams.delete(LANGUAGES_QUERY_PARAM);
143+
}
144+
145+
if (newParams.toString() !== searchParams.toString()) {
146+
router.replace(`?${newParams.toString()}`, { scroll: false });
147+
}
148+
}}
130149
className="overflow-auto"
131150
/>
132151
</div>

packages/web/src/app/[domain]/search/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const SearchPageInternal = () => {
4747
const domain = useDomain();
4848
const { toast } = useToast();
4949

50-
const { data: searchResponse, isLoading, error } = useQuery({
50+
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
5151
queryKey: ["search", searchQuery, maxMatchDisplayCount],
5252
queryFn: () => measure(() => unwrapServiceError(search({
5353
query: searchQuery,
@@ -91,7 +91,7 @@ const SearchPageInternal = () => {
9191
// repository metadata (like host type, repo name, etc.)
9292
// Convert this into a map of repo name to repo metadata
9393
// for easy lookup.
94-
const { data: repoMetadata } = useQuery({
94+
const { data: repoMetadata, isLoading: isRepoMetadataLoading } = useQuery({
9595
queryKey: ["repos"],
9696
queryFn: () => getRepos(domain),
9797
select: (data): Record<string, Repository> =>
@@ -194,7 +194,7 @@ const SearchPageInternal = () => {
194194
<Separator />
195195
</div>
196196

197-
{isLoading ? (
197+
{(isSearchLoading || isRepoMetadataLoading) ? (
198198
<div className="flex flex-col items-center justify-center h-full gap-2">
199199
<SymbolIcon className="h-6 w-6 animate-spin" />
200200
<p className="font-semibold text-center">Searching...</p>

packages/web/src/hooks/useNonEmptyQueryParam.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import { useMemo } from "react";
1717
*/
1818
export const useNonEmptyQueryParam = (param: string) => {
1919
const searchParams = useSearchParams();
20-
const inviteId = useMemo(() => {
20+
const paramValue = useMemo(() => {
2121
return getSearchParam(param, searchParams);
2222
}, [param, searchParams]);
2323

24-
return inviteId;
24+
return paramValue;
2525
};
2626

2727
/**

0 commit comments

Comments
 (0)