Skip to content

Commit 617cb4e

Browse files
committed
feat(search): implement super search with hybrid BM25+vector search
1 parent 2276291 commit 617cb4e

33 files changed

Lines changed: 4119 additions & 32 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/components/cmd-k.tsx

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
FolderGit2,
1717
GitPullRequestIcon,
1818
TimerIcon,
19+
BriefcaseIcon,
1920
} from "lucide-react";
2021
import { ListPullRequest } from "@/lib/api/queries/pullRequests";
2122
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -35,9 +36,13 @@ import {
3536
rememberLastProjectAtom,
3637
buildRememberedTimerParams,
3738
} from "@/lib/milltime-preferences";
39+
import { SearchResult } from "@/lib/api/queries/search";
40+
import { useDebounce } from "@/hooks/useDebounce";
3841

3942
export function CmdK() {
4043
const [open, setOpen] = React.useState(false);
44+
const [searchInput, setSearchInput] = React.useState("");
45+
const debouncedSearchInput = useDebounce(searchInput, 300);
4146

4247
const close = () => setOpen(false);
4348

@@ -53,13 +58,27 @@ export function CmdK() {
5358
return () => document.removeEventListener("keydown", down);
5459
}, []);
5560

61+
React.useEffect(() => {
62+
if (!open) {
63+
setSearchInput("");
64+
}
65+
}, [open]);
66+
5667
return (
5768
<CommandDialog open={open} onOpenChange={setOpen}>
58-
<CommandInput placeholder="Type a command or search..." />
69+
<CommandInput
70+
placeholder="Type a command or search..."
71+
value={searchInput}
72+
onValueChange={setSearchInput}
73+
/>
5974
<CommandList className="max-w-2xl">
6075
<CommandEmpty>No results found.</CommandEmpty>
6176
<PagesCommandGroup close={close} />
6277
<ActionsCommandGroup close={close} />
78+
<SearchCommandGroup
79+
close={close}
80+
searchQuery={debouncedSearchInput}
81+
/>
6382
<PRCommandGroup close={close} />
6483
</CommandList>
6584
</CommandDialog>
@@ -218,7 +237,7 @@ function ActionsCommandGroup(props: { close: () => void }) {
218237
function PRCommandGroup(props: { close: () => void }) {
219238
const navigate = useNavigate();
220239

221-
const { data: pullRequests } = useQuery(queries.listPullRequests());
240+
const { data: pullRequests } = useQuery(queries.pullRequests.listPullRequests());
222241

223242
return (
224243
<CommandGroup heading="Pull requests">
@@ -262,3 +281,64 @@ function PRCommandGroup(props: { close: () => void }) {
262281
function pullRequestValue(pr: ListPullRequest) {
263282
return `!${pr.id} ${pr.title} ${pr.repoName} ${pr.createdBy.displayName} ${pr.workItems.map((wi) => `#${wi.id}`).join(" ")}`;
264283
}
284+
285+
function SearchCommandGroup(props: {
286+
close: () => void;
287+
searchQuery: string;
288+
}) {
289+
const navigate = useNavigate();
290+
291+
const { data: searchResults } = useQuery(
292+
queries.search.search(props.searchQuery, 10)
293+
);
294+
295+
if (!props.searchQuery || !searchResults || searchResults.length === 0) {
296+
return null;
297+
}
298+
299+
return (
300+
<CommandGroup heading="Search results">
301+
{searchResults.map((result) => (
302+
<CommandItem
303+
key={`${result.sourceType}-${result.externalId}`}
304+
value={searchResultValue(result)}
305+
onSelect={() => {
306+
if (result.sourceType === "Pr") {
307+
navigate({
308+
to: "/prs/$prId",
309+
params: { prId: result.externalId.toString() },
310+
});
311+
} else {
312+
window.open(result.url, "_blank", "noopener,noreferrer");
313+
}
314+
props.close();
315+
}}
316+
>
317+
<div className="flex w-full flex-row items-center justify-between gap-2 truncate">
318+
<div className="flex max-w-[75%] flex-row items-center gap-2">
319+
{result.sourceType === "Pr" ? (
320+
<GitPullRequestIcon className="h-4 w-4 text-muted-foreground" />
321+
) : (
322+
<BriefcaseIcon className="h-4 w-4 text-muted-foreground" />
323+
)}
324+
<span className="text-muted-foreground">
325+
{result.sourceType === "Pr" ? "!" : "#"}
326+
{result.externalId}
327+
</span>
328+
<span className="truncate">{result.title}</span>
329+
</div>
330+
{result.authorName && (
331+
<span className="text-xs text-muted-foreground">
332+
{result.authorName}
333+
</span>
334+
)}
335+
</div>
336+
</CommandItem>
337+
))}
338+
</CommandGroup>
339+
);
340+
}
341+
342+
function searchResultValue(result: SearchResult) {
343+
return `${result.sourceType === "Pr" ? "!" : "#"}${result.externalId} ${result.title} ${result.authorName ?? ""}`;
344+
}

app/src/hooks/useDebounce.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useEffect, useState } from "react";
2+
3+
export function useDebounce<T>(value: T, delay: number): T {
4+
const [debouncedValue, setDebouncedValue] = useState(value);
5+
6+
useEffect(() => {
7+
const timer = setTimeout(() => {
8+
setDebouncedValue(value);
9+
}, delay);
10+
11+
return () => {
12+
clearTimeout(timer);
13+
};
14+
}, [value, delay]);
15+
16+
return debouncedValue;
17+
}

app/src/lib/api/mutations/differs.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ function useStartDiffers(options?: DefaultMutationOptions<RepoKey>) {
1818
}),
1919
...options,
2020
onMutate: (repoKey) => {
21-
queryClient.cancelQueries(queries.differs());
22-
const previous = queryClient.getQueryData(queries.differs().queryKey);
21+
queryClient.cancelQueries(queries.differs.differs());
22+
const previous = queryClient.getQueryData(queries.differs.differs().queryKey);
2323

24-
queryClient.setQueryData(queries.differs().queryKey, (old) => {
24+
queryClient.setQueryData(queries.differs.differs().queryKey, (old) => {
2525
if (!old) return undefined;
2626

2727
const differ = old.find(
@@ -45,11 +45,11 @@ function useStartDiffers(options?: DefaultMutationOptions<RepoKey>) {
4545
return { previous };
4646
},
4747
onError: (err, vars, ctx) => {
48-
queryClient.setQueryData(queries.differs().queryKey, ctx?.previous);
48+
queryClient.setQueryData(queries.differs.differs().queryKey, ctx?.previous);
4949
options?.onError?.(err, vars, ctx);
5050
},
5151
onSuccess: (data, vars, ctx) => {
52-
queryClient.invalidateQueries(queries.differs());
52+
queryClient.invalidateQueries(queries.differs.differs());
5353
queryClient.invalidateQueries({
5454
queryKey: pullRequestsQueries.baseKey,
5555
});
@@ -69,10 +69,10 @@ function useStopDiffers(options?: DefaultMutationOptions<RepoKey>) {
6969
}),
7070
...options,
7171
onMutate: (repoKey) => {
72-
queryClient.cancelQueries(queries.differs());
73-
const previous = queryClient.getQueryData(queries.differs().queryKey);
72+
queryClient.cancelQueries(queries.differs.differs());
73+
const previous = queryClient.getQueryData(queries.differs.differs().queryKey);
7474

75-
queryClient.setQueryData(queries.differs().queryKey, (old) => {
75+
queryClient.setQueryData(queries.differs.differs().queryKey, (old) => {
7676
if (!old) return undefined;
7777

7878
const differ = old.find(
@@ -96,11 +96,11 @@ function useStopDiffers(options?: DefaultMutationOptions<RepoKey>) {
9696
return { previous };
9797
},
9898
onError: (err, vars, ctx) => {
99-
queryClient.setQueryData(queries.differs().queryKey, ctx?.previous);
99+
queryClient.setQueryData(queries.differs.differs().queryKey, ctx?.previous);
100100
options?.onError?.(err, vars, ctx);
101101
},
102102
onSuccess: (data, vars, ctx) => {
103-
queryClient.invalidateQueries(queries.differs());
103+
queryClient.invalidateQueries(queries.differs.differs());
104104
options?.onSuccess?.(data, vars, ctx);
105105
},
106106
});

app/src/lib/api/mutations/repositories.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function useAddRepository(options?: DefaultMutationOptions<AddRepositoryBody>) {
2222
}),
2323
...options,
2424
onSuccess: (data, vars, ctx) => {
25-
queryClient.invalidateQueries(queries.differs());
25+
queryClient.invalidateQueries(queries.differs.differs());
2626
options?.onSuccess?.(data, vars, ctx);
2727
},
2828
});
@@ -43,7 +43,7 @@ function useFollowRepository(
4343
}),
4444
...options,
4545
onMutate: (vars) => {
46-
queryClient.setQueryData(queries.differs().queryKey, (old) => {
46+
queryClient.setQueryData(queries.differs.differs().queryKey, (old) => {
4747
if (!old) return old;
4848

4949
const idx = old.findIndex(
@@ -62,7 +62,7 @@ function useFollowRepository(
6262
options?.onMutate?.(vars);
6363
},
6464
onSettled: (data, err, vars, ctx) => {
65-
queryClient.invalidateQueries(queries.differs());
65+
queryClient.invalidateQueries(queries.differs.differs());
6666
queryClient.invalidateQueries({
6767
queryKey: pullRequestsQueries.baseKey,
6868
});
@@ -84,7 +84,7 @@ function useDeleteRepository(
8484
}),
8585
...options,
8686
onSuccess: (data, vars, ctx) => {
87-
queryClient.invalidateQueries(queries.differs());
87+
queryClient.invalidateQueries(queries.differs.differs());
8888
queryClient.invalidateQueries({
8989
queryKey: pullRequestsQueries.baseKey,
9090
});

app/src/lib/api/queries/queries.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { pullRequestsQueries } from "./pullRequests";
33
import { commitsQueries } from "./commits";
44
import { milltimeQueries } from "./milltime";
55
import { userQueries } from "./user";
6+
import { searchQueries } from "./search";
67

78
export const queries = {
8-
...userQueries,
9-
...differsQueries,
10-
...pullRequestsQueries,
11-
...commitsQueries,
12-
...milltimeQueries,
9+
user: userQueries,
10+
differs: differsQueries,
11+
pullRequests: pullRequestsQueries,
12+
commits: commitsQueries,
13+
milltime: milltimeQueries,
14+
search: searchQueries,
1315
};
1416

1517
export type RepoKey<T = object> = T & {

app/src/lib/api/queries/search.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { queryOptions } from "@tanstack/react-query";
2+
import { api } from "../api";
3+
4+
export const searchQueries = {
5+
baseKey: ["search"],
6+
search: (query: string, limit?: number) =>
7+
queryOptions({
8+
queryKey: [...searchQueries.baseKey, query, limit],
9+
queryFn: async () => {
10+
const params = new URLSearchParams({ q: query });
11+
if (limit) {
12+
params.set("limit", limit.toString());
13+
}
14+
return api.get(`search?${params}`).json<Array<SearchResult>>();
15+
},
16+
enabled: query.length >= 2,
17+
}),
18+
};
19+
20+
export type SearchSource = "Pr" | "WorkItem";
21+
22+
export type SearchResult = {
23+
id: number;
24+
sourceType: SearchSource;
25+
sourceId: string;
26+
externalId: number;
27+
title: string;
28+
description: string | null;
29+
status: string;
30+
priority: number | null;
31+
itemType: string | null;
32+
authorName: string | null;
33+
url: string;
34+
createdAt: string;
35+
updatedAt: string;
36+
score: number;
37+
};

app/src/routes/_layout/prs/$prId/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { milltimeMutations } from "@/lib/api/mutations/milltime";
4141

4242
export const Route = createFileRoute("/_layout/prs/$prId")({
4343
loader: ({ context }) =>
44-
context.queryClient.ensureQueryData(queries.listPullRequests()),
44+
context.queryClient.ensureQueryData(queries.pullRequests.listPullRequests()),
4545
component: PRDetailsDialog,
4646
});
4747

@@ -51,7 +51,7 @@ function PRDetailsDialog() {
5151
const navigate = useNavigate({ from: Route.fullPath });
5252

5353
const { data: pr } = useSuspenseQuery({
54-
...queries.listPullRequests(),
54+
...queries.pullRequests.listPullRequests(),
5555
select: (data) => data.find((pr) => pr.id === +prId),
5656
});
5757

app/src/routes/_layout/prs/route.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export const Route = createFileRoute("/_layout/prs")({
2828
shouldReload: false,
2929
loader: async ({ context }) => {
3030
await Promise.all([
31-
context.queryClient.ensureQueryData(queries.me()),
32-
context.queryClient.ensureQueryData(queries.listPullRequests()),
31+
context.queryClient.ensureQueryData(queries.user.me()),
32+
context.queryClient.ensureQueryData(queries.pullRequests.listPullRequests()),
3333
]);
3434
},
3535
component: PrsComponent,
@@ -40,9 +40,9 @@ function PrsComponent() {
4040
const { searchString, filterAuthor, filterReviewer, filterBlocking } =
4141
Route.useSearch();
4242

43-
const { data: user } = useSuspenseQuery(queries.me());
43+
const { data: user } = useSuspenseQuery(queries.user.me());
4444
const { data: pullRequests } = useSuspenseQuery({
45-
...queries.listPullRequests(),
45+
...queries.pullRequests.listPullRequests(),
4646
refetchInterval: 60 * 1000,
4747
});
4848

app/src/routes/_layout/repositories/route.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ const repositoriesSearchSchema = z.object({
2222
export const Route = createFileRoute("/_layout/repositories")({
2323
validateSearch: repositoriesSearchSchema,
2424
loader: ({ context }) => {
25-
context.queryClient.ensureQueryData(queries.me());
26-
context.queryClient.ensureQueryData(queries.differs());
25+
context.queryClient.ensureQueryData(queries.user.me());
26+
context.queryClient.ensureQueryData(queries.differs.differs());
2727
},
2828
component: RepositoriesComponent,
2929
});
@@ -32,11 +32,11 @@ function RepositoriesComponent() {
3232
const { searchString } = Route.useSearch();
3333

3434
const { data: isAdmin } = useSuspenseQuery({
35-
...queries.me(),
35+
...queries.user.me(),
3636
select: (data) => data.roles.includes("Admin"),
3737
});
3838
const { data, dataUpdatedAt } = useSuspenseQuery({
39-
...queries.differs(),
39+
...queries.differs.differs(),
4040
refetchInterval: 15 * 1000,
4141
});
4242

0 commit comments

Comments
 (0)