Skip to content

Commit 37ce151

Browse files
feature: basic file search (#341)
1 parent eb6d58d commit 37ce151

File tree

10 files changed

+402
-27
lines changed

10 files changed

+402
-27
lines changed

CHANGELOG.md

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

1010
### Added
1111
- Changed repository link in search to file tree + move external link to code host logo. [#340](https://github.com/sourcebot-dev/sourcebot/pull/340)
12+
- Added a basic file search dialog when browsing a repository. [#341](https://github.com/sourcebot-dev/sourcebot/pull/341)
1213

1314
## [4.2.0] - 2025-06-09
1415

packages/web/src/app/[domain]/browse/browseStateProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface BrowseState {
1212
}
1313
isBottomPanelCollapsed: boolean;
1414
isFileTreePanelCollapsed: boolean;
15+
isFileSearchOpen: boolean;
1516
activeExploreMenuTab: "references" | "definitions";
1617
bottomPanelSize: number;
1718
}
@@ -20,6 +21,7 @@ const defaultState: BrowseState = {
2021
selectedSymbolInfo: undefined,
2122
isBottomPanelCollapsed: true,
2223
isFileTreePanelCollapsed: false,
24+
isFileSearchOpen: false,
2325
activeExploreMenuTab: "references",
2426
bottomPanelSize: 35,
2527
};
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
'use client';
2+
3+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
4+
import { useState, useRef, useMemo, useEffect, useCallback } from "react";
5+
import { useHotkeys } from "react-hotkeys-hook";
6+
import { useQuery } from "@tanstack/react-query";
7+
import { unwrapServiceError } from "@/lib/utils";
8+
import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
9+
import { useDomain } from "@/hooks/useDomain";
10+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
11+
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
12+
import { useBrowseState } from "../hooks/useBrowseState";
13+
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
14+
import { useBrowseParams } from "../hooks/useBrowseParams";
15+
import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon";
16+
import { useLocalStorage } from "usehooks-ts";
17+
import { Skeleton } from "@/components/ui/skeleton";
18+
19+
const MAX_RESULTS = 100;
20+
21+
type SearchResult = {
22+
file: FileTreeItem;
23+
match?: {
24+
from: number;
25+
to: number;
26+
};
27+
}
28+
29+
30+
export const FileSearchCommandDialog = () => {
31+
const { repoName, revisionName } = useBrowseParams();
32+
const domain = useDomain();
33+
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
34+
35+
const commandListRef = useRef<HTMLDivElement>(null);
36+
const inputRef = useRef<HTMLInputElement>(null);
37+
const [searchQuery, setSearchQuery] = useState('');
38+
const { navigateToPath } = useBrowseNavigation();
39+
const { prefetchFileSource } = usePrefetchFileSource();
40+
41+
const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []);
42+
43+
useHotkeys("mod+p", (event) => {
44+
event.preventDefault();
45+
updateBrowseState({
46+
isFileSearchOpen: !isFileSearchOpen,
47+
});
48+
}, {
49+
enableOnFormTags: true,
50+
enableOnContentEditable: true,
51+
description: "Open File Search",
52+
});
53+
54+
// Whenever we open the dialog, clear the search query
55+
useEffect(() => {
56+
if (isFileSearchOpen) {
57+
setSearchQuery('');
58+
}
59+
}, [isFileSearchOpen]);
60+
61+
const { data: files, isLoading, isError } = useQuery({
62+
queryKey: ['files', repoName, revisionName, domain],
63+
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)),
64+
enabled: isFileSearchOpen,
65+
});
66+
67+
const { filteredFiles, maxResultsHit } = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => {
68+
if (!files || isLoading) {
69+
return {
70+
filteredFiles: [],
71+
maxResultsHit: false,
72+
};
73+
}
74+
75+
const matches = files
76+
.map((file) => {
77+
return {
78+
file,
79+
matchIndex: file.path.toLowerCase().indexOf(searchQuery.toLowerCase()),
80+
}
81+
})
82+
.filter(({ matchIndex }) => {
83+
return matchIndex !== -1;
84+
});
85+
86+
return {
87+
filteredFiles: matches
88+
.slice(0, MAX_RESULTS)
89+
.map(({ file, matchIndex }) => {
90+
return {
91+
file,
92+
match: {
93+
from: matchIndex,
94+
to: matchIndex + searchQuery.length - 1,
95+
},
96+
}
97+
}),
98+
maxResultsHit: matches.length > MAX_RESULTS,
99+
}
100+
}, [searchQuery, files, isLoading]);
101+
102+
// Scroll to the top of the list whenever the search query changes
103+
useEffect(() => {
104+
commandListRef.current?.scrollTo({
105+
top: 0,
106+
})
107+
}, [searchQuery]);
108+
109+
const onSelect = useCallback((file: FileTreeItem) => {
110+
setRecentlyOpened((prev) => {
111+
const filtered = prev.filter(f => f.path !== file.path);
112+
return [file, ...filtered];
113+
});
114+
navigateToPath({
115+
repoName,
116+
revisionName,
117+
path: file.path,
118+
pathType: 'blob',
119+
});
120+
updateBrowseState({
121+
isFileSearchOpen: false,
122+
});
123+
}, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]);
124+
125+
const onMouseEnter = useCallback((file: FileTreeItem) => {
126+
prefetchFileSource(
127+
repoName,
128+
revisionName ?? 'HEAD',
129+
file.path
130+
);
131+
}, [prefetchFileSource, repoName, revisionName]);
132+
133+
// @note: We were hitting issues when the user types into the input field while the files are still
134+
// loading. The workaround was to set `disabled` when loading and then focus the input field when
135+
// the files are loaded, hence the `useEffect` below.
136+
useEffect(() => {
137+
if (!isLoading) {
138+
inputRef.current?.focus();
139+
}
140+
}, [isLoading]);
141+
142+
return (
143+
<Dialog
144+
open={isFileSearchOpen}
145+
onOpenChange={(isOpen) => {
146+
updateBrowseState({
147+
isFileSearchOpen: isOpen,
148+
});
149+
}}
150+
modal={true}
151+
>
152+
<DialogContent
153+
className="overflow-hidden p-0 shadow-lg max-w-[90vw] sm:max-w-2xl top-[20%] translate-y-0"
154+
>
155+
<DialogTitle className="sr-only">Search for files</DialogTitle>
156+
<DialogDescription className="sr-only">{`Search for files in the repository ${repoName}.`}</DialogDescription>
157+
<Command
158+
shouldFilter={false}
159+
>
160+
<CommandInput
161+
placeholder={`Search for files in ${repoName}...`}
162+
onValueChange={setSearchQuery}
163+
disabled={isLoading}
164+
ref={inputRef}
165+
/>
166+
{
167+
isLoading ? (
168+
<ResultsSkeleton />
169+
) : isError ? (
170+
<p>Error loading files.</p>
171+
) : (
172+
<CommandList ref={commandListRef}>
173+
{searchQuery.length === 0 ? (
174+
<CommandGroup
175+
heading="Recently opened"
176+
>
177+
<CommandEmpty className="text-muted-foreground text-center text-sm py-6">No recently opened files.</CommandEmpty>
178+
{recentlyOpened.map((file) => {
179+
return (
180+
<SearchResultComponent
181+
key={file.path}
182+
file={file}
183+
onSelect={() => onSelect(file)}
184+
onMouseEnter={() => onMouseEnter(file)}
185+
/>
186+
);
187+
})}
188+
</CommandGroup>
189+
) : (
190+
<>
191+
<CommandEmpty className="text-muted-foreground text-center text-sm py-6">No results found.</CommandEmpty>
192+
{filteredFiles.map(({ file, match }) => {
193+
return (
194+
<SearchResultComponent
195+
key={file.path}
196+
file={file}
197+
match={match}
198+
onSelect={() => onSelect(file)}
199+
onMouseEnter={() => onMouseEnter(file)}
200+
/>
201+
);
202+
})}
203+
{maxResultsHit && (
204+
<div className="text-muted-foreground text-center text-sm py-4">
205+
Maximum results hit. Please refine your search.
206+
</div>
207+
)}
208+
</>
209+
)}
210+
</CommandList>
211+
)
212+
}
213+
</Command>
214+
</DialogContent>
215+
</Dialog>
216+
)
217+
}
218+
219+
interface SearchResultComponentProps {
220+
file: FileTreeItem;
221+
match?: {
222+
from: number;
223+
to: number;
224+
};
225+
onSelect: () => void;
226+
onMouseEnter: () => void;
227+
}
228+
229+
const SearchResultComponent = ({
230+
file,
231+
match,
232+
onSelect,
233+
onMouseEnter,
234+
}: SearchResultComponentProps) => {
235+
return (
236+
<CommandItem
237+
key={file.path}
238+
onSelect={onSelect}
239+
onMouseEnter={onMouseEnter}
240+
>
241+
<div className="flex flex-row gap-2 w-full cursor-pointer relative">
242+
<FileTreeItemIcon item={file} className="mt-1" />
243+
<div className="flex flex-col w-full">
244+
<span className="text-sm font-medium">
245+
{file.name}
246+
</span>
247+
<span className="text-xs text-muted-foreground">
248+
{match ? (
249+
<Highlight text={file.path} range={match} />
250+
) : (
251+
file.path
252+
)}
253+
</span>
254+
</div>
255+
</div>
256+
</CommandItem>
257+
);
258+
}
259+
260+
const Highlight = ({ text, range }: { text: string, range: { from: number; to: number } }) => {
261+
return (
262+
<span>
263+
{text.slice(0, range.from)}
264+
<span className="searchMatch-selected">{text.slice(range.from, range.to + 1)}</span>
265+
{text.slice(range.to + 1)}
266+
</span>
267+
)
268+
}
269+
270+
const ResultsSkeleton = () => {
271+
return (
272+
<div className="p-2">
273+
{Array.from({ length: 6 }).map((_, index) => (
274+
<div key={index} className="flex flex-row gap-2 p-2 mb-1">
275+
<Skeleton className="w-4 h-4" />
276+
<div className="flex flex-col w-full gap-1">
277+
<Skeleton className="h-4 w-1/4" />
278+
<Skeleton className="h-3 w-1/2" />
279+
</div>
280+
</div>
281+
))}
282+
</div>
283+
);
284+
};

packages/web/src/app/[domain]/browse/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel";
88
import { TopBar } from "@/app/[domain]/components/topBar";
99
import { Separator } from '@/components/ui/separator';
1010
import { useBrowseParams } from "./hooks/useBrowseParams";
11+
import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog";
1112

1213
interface LayoutProps {
1314
children: React.ReactNode;
@@ -62,6 +63,7 @@ export default function Layout({
6263
</ResizablePanel>
6364
</ResizablePanelGroup>
6465
</div>
66+
<FileSearchCommandDialog />
6567
</BrowseStateProvider>
6668
);
67-
}
69+
}

packages/web/src/components/ui/dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
2121
<DialogPrimitive.Overlay
2222
ref={ref}
2323
className={cn(
24-
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24+
"fixed inset-0 z-50 bg-black/80",
2525
className
2626
)}
2727
{...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
3838
<DialogPrimitive.Content
3939
ref={ref}
4040
className={cn(
41-
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
41+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg",
4242
className
4343
)}
4444
{...props}

packages/web/src/features/entitlements/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const getPlan = (): Plan => {
7373
if (licenseKey) {
7474
const expiryDate = new Date(licenseKey.expiryDate);
7575
if (expiryDate.getTime() < new Date().getTime()) {
76-
logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`);
76+
logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`);
7777
process.exit(1);
7878
}
7979

0 commit comments

Comments
 (0)