Skip to content

feature: basic file search #341

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

## [4.2.0] - 2025-06-09

Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/app/[domain]/browse/browseStateProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface BrowseState {
}
isBottomPanelCollapsed: boolean;
isFileTreePanelCollapsed: boolean;
isFileSearchOpen: boolean;
activeExploreMenuTab: "references" | "definitions";
bottomPanelSize: number;
}
Expand All @@ -20,6 +21,7 @@ const defaultState: BrowseState = {
selectedSymbolInfo: undefined,
isBottomPanelCollapsed: true,
isFileTreePanelCollapsed: false,
isFileSearchOpen: false,
activeExploreMenuTab: "references",
bottomPanelSize: 35,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
'use client';

import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { useState, useRef, useMemo, useEffect, useCallback } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
import { useDomain } from "@/hooks/useDomain";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
import { useBrowseState } from "../hooks/useBrowseState";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import { useBrowseParams } from "../hooks/useBrowseParams";
import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon";
import { useLocalStorage } from "usehooks-ts";
import { Skeleton } from "@/components/ui/skeleton";

const MAX_RESULTS = 100;

type SearchResult = {
file: FileTreeItem;
match?: {
from: number;
to: number;
};
}


export const FileSearchCommandDialog = () => {
const { repoName, revisionName } = useBrowseParams();
const domain = useDomain();
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();

const commandListRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation();
const { prefetchFileSource } = usePrefetchFileSource();

const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []);

useHotkeys("mod+p", (event) => {
event.preventDefault();
updateBrowseState({
isFileSearchOpen: !isFileSearchOpen,
});
}, {
enableOnFormTags: true,
enableOnContentEditable: true,
description: "Open File Search",
});

// Whenever we open the dialog, clear the search query
useEffect(() => {
if (isFileSearchOpen) {
setSearchQuery('');
}
}, [isFileSearchOpen]);

const { data: files, isLoading, isError } = useQuery({
queryKey: ['files', repoName, revisionName, domain],
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)),
enabled: isFileSearchOpen,
});

const { filteredFiles, maxResultsHit } = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => {
if (!files || isLoading) {
return {
filteredFiles: [],
maxResultsHit: false,
};
}

const matches = files
.map((file) => {
return {
file,
matchIndex: file.path.toLowerCase().indexOf(searchQuery.toLowerCase()),
}
})
.filter(({ matchIndex }) => {
return matchIndex !== -1;
});

return {
filteredFiles: matches
.slice(0, MAX_RESULTS)
.map(({ file, matchIndex }) => {
return {
file,
match: {
from: matchIndex,
to: matchIndex + searchQuery.length - 1,
},
}
}),
maxResultsHit: matches.length > MAX_RESULTS,
}
}, [searchQuery, files, isLoading]);

// Scroll to the top of the list whenever the search query changes
useEffect(() => {
commandListRef.current?.scrollTo({
top: 0,
})
}, [searchQuery]);

const onSelect = useCallback((file: FileTreeItem) => {
setRecentlyOpened((prev) => {
const filtered = prev.filter(f => f.path !== file.path);
return [file, ...filtered];
});
navigateToPath({
repoName,
revisionName,
path: file.path,
pathType: 'blob',
});
updateBrowseState({
isFileSearchOpen: false,
});
}, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]);

const onMouseEnter = useCallback((file: FileTreeItem) => {
prefetchFileSource(
repoName,
revisionName ?? 'HEAD',
file.path
);
}, [prefetchFileSource, repoName, revisionName]);

// @note: We were hitting issues when the user types into the input field while the files are still
// loading. The workaround was to set `disabled` when loading and then focus the input field when
// the files are loaded, hence the `useEffect` below.
useEffect(() => {
if (!isLoading) {
inputRef.current?.focus();
}
}, [isLoading]);

return (
<Dialog
open={isFileSearchOpen}
onOpenChange={(isOpen) => {
updateBrowseState({
isFileSearchOpen: isOpen,
});
}}
modal={true}
>
<DialogContent
className="overflow-hidden p-0 shadow-lg max-w-[90vw] sm:max-w-2xl top-[20%] translate-y-0"
>
<DialogTitle className="sr-only">Search for files</DialogTitle>
<DialogDescription className="sr-only">{`Search for files in the repository ${repoName}.`}</DialogDescription>
<Command
shouldFilter={false}
>
<CommandInput
placeholder={`Search for files in ${repoName}...`}
onValueChange={setSearchQuery}
disabled={isLoading}
ref={inputRef}
/>
{
isLoading ? (
<ResultsSkeleton />
) : isError ? (
<p>Error loading files.</p>
) : (
<CommandList ref={commandListRef}>
{searchQuery.length === 0 ? (
<CommandGroup
heading="Recently opened"
>
<CommandEmpty className="text-muted-foreground text-center text-sm py-6">No recently opened files.</CommandEmpty>
{recentlyOpened.map((file) => {
return (
<SearchResultComponent
key={file.path}
file={file}
onSelect={() => onSelect(file)}
onMouseEnter={() => onMouseEnter(file)}
/>
);
})}
</CommandGroup>
) : (
<>
<CommandEmpty className="text-muted-foreground text-center text-sm py-6">No results found.</CommandEmpty>
{filteredFiles.map(({ file, match }) => {
return (
<SearchResultComponent
key={file.path}
file={file}
match={match}
onSelect={() => onSelect(file)}
onMouseEnter={() => onMouseEnter(file)}
/>
);
})}
{maxResultsHit && (
<div className="text-muted-foreground text-center text-sm py-4">
Maximum results hit. Please refine your search.
</div>
)}
</>
)}
</CommandList>
)
}
</Command>
</DialogContent>
</Dialog>
)
}

interface SearchResultComponentProps {
file: FileTreeItem;
match?: {
from: number;
to: number;
};
onSelect: () => void;
onMouseEnter: () => void;
}

const SearchResultComponent = ({
file,
match,
onSelect,
onMouseEnter,
}: SearchResultComponentProps) => {
return (
<CommandItem
key={file.path}
onSelect={onSelect}
onMouseEnter={onMouseEnter}
>
<div className="flex flex-row gap-2 w-full cursor-pointer relative">
<FileTreeItemIcon item={file} className="mt-1" />
<div className="flex flex-col w-full">
<span className="text-sm font-medium">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{match ? (
<Highlight text={file.path} range={match} />
) : (
file.path
)}
</span>
</div>
</div>
</CommandItem>
);
}

const Highlight = ({ text, range }: { text: string, range: { from: number; to: number } }) => {
return (
<span>
{text.slice(0, range.from)}
<span className="searchMatch-selected">{text.slice(range.from, range.to + 1)}</span>
{text.slice(range.to + 1)}
</span>
)
}

const ResultsSkeleton = () => {
return (
<div className="p-2">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex flex-row gap-2 p-2 mb-1">
<Skeleton className="w-4 h-4" />
<div className="flex flex-col w-full gap-1">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
);
};
4 changes: 3 additions & 1 deletion packages/web/src/app/[domain]/browse/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel";
import { TopBar } from "@/app/[domain]/components/topBar";
import { Separator } from '@/components/ui/separator';
import { useBrowseParams } from "./hooks/useBrowseParams";
import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog";

interface LayoutProps {
children: React.ReactNode;
Expand Down Expand Up @@ -62,6 +63,7 @@ export default function Layout({
</ResizablePanel>
</ResizablePanelGroup>
</div>
<FileSearchCommandDialog />
</BrowseStateProvider>
);
}
}
4 changes: 2 additions & 2 deletions packages/web/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"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",
"fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
Expand All @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"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",
"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",
className
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/features/entitlements/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const getPlan = (): Plan => {
if (licenseKey) {
const expiryDate = new Date(licenseKey.expiryDate);
if (expiryDate.getTime() < new Date().getTime()) {
logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`);
logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`);
process.exit(1);
}

Expand Down
Loading