feature: basic file search (#341)

This commit is contained in:
Brendan Kellam 2025-06-09 12:51:35 -07:00 committed by GitHub
parent eb6d58d6d3
commit 37ce151603
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 402 additions and 27 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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) - 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 ## [4.2.0] - 2025-06-09

View file

@ -12,6 +12,7 @@ export interface BrowseState {
} }
isBottomPanelCollapsed: boolean; isBottomPanelCollapsed: boolean;
isFileTreePanelCollapsed: boolean; isFileTreePanelCollapsed: boolean;
isFileSearchOpen: boolean;
activeExploreMenuTab: "references" | "definitions"; activeExploreMenuTab: "references" | "definitions";
bottomPanelSize: number; bottomPanelSize: number;
} }
@ -20,6 +21,7 @@ const defaultState: BrowseState = {
selectedSymbolInfo: undefined, selectedSymbolInfo: undefined,
isBottomPanelCollapsed: true, isBottomPanelCollapsed: true,
isFileTreePanelCollapsed: false, isFileTreePanelCollapsed: false,
isFileSearchOpen: false,
activeExploreMenuTab: "references", activeExploreMenuTab: "references",
bottomPanelSize: 35, bottomPanelSize: 35,
}; };

View file

@ -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>
);
};

View file

@ -8,6 +8,7 @@ import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel";
import { TopBar } from "@/app/[domain]/components/topBar"; import { TopBar } from "@/app/[domain]/components/topBar";
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useBrowseParams } from "./hooks/useBrowseParams"; import { useBrowseParams } from "./hooks/useBrowseParams";
import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -62,6 +63,7 @@ export default function Layout({
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
<FileSearchCommandDialog />
</BrowseStateProvider> </BrowseStateProvider>
); );
} }

View file

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View file

@ -73,7 +73,7 @@ export const getPlan = (): Plan => {
if (licenseKey) { if (licenseKey) {
const expiryDate = new Date(licenseKey.expiryDate); const expiryDate = new Date(licenseKey.expiryDate);
if (expiryDate.getTime() < new Date().getTime()) { 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); process.exit(1);
} }

View file

@ -155,7 +155,58 @@ export const getFolderContents = async (params: { repoName: string, revisionName
return contents; return contents;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
) );
export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ org }) => {
const { repoName, revisionName } = params;
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});
if (!repo) {
return notFound();
}
const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);
let result: string;
try {
result = await git.raw([
'ls-tree',
revisionName,
// recursive
'-r',
// only return the names of the files
'--name-only',
]);
} catch (error) {
logger.error('git ls-tree failed.', { error });
return unexpectedError('git ls-tree command failed.');
}
const paths = result.split('\n').filter(line => line.trim());
const files: FileTreeItem[] = paths.map(path => {
const name = path.split('/').pop() ?? '';
return {
type: 'blob',
path,
name,
}
});
return files;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true)
);
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
const root: FileTreeNode = { const root: FileTreeNode = {

View file

@ -1,12 +1,11 @@
'use client'; 'use client';
import { FileTreeItem } from "../actions"; import { FileTreeItem } from "../actions";
import { useMemo, useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { getIconForFile, getIconForFolder } from "vscode-icons-js";
import { Icon } from '@iconify/react';
import clsx from "clsx"; import clsx from "clsx";
import scrollIntoView from 'scroll-into-view-if-needed'; import scrollIntoView from 'scroll-into-view-if-needed';
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import { FileTreeItemIcon } from "./fileTreeItemIcon";
export const FileTreeItemComponent = ({ export const FileTreeItemComponent = ({
node, node,
@ -53,24 +52,6 @@ export const FileTreeItemComponent = ({
} }
}, [isActive, parentRef]); }, [isActive, parentRef]);
const iconName = useMemo(() => {
if (node.type === 'tree') {
const icon = getIconForFolder(node.name);
if (icon) {
const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
return iconName;
}
} else if (node.type === 'blob') {
const icon = getIconForFile(node.name);
if (icon) {
const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
return iconName;
}
}
return "vscode-icons:file-type-unknown";
}, [node.name, node.type]);
return ( return (
<div <div
ref={ref} ref={ref}
@ -99,7 +80,7 @@ export const FileTreeItemComponent = ({
) )
)} )}
</div> </div>
<Icon icon={iconName} className="w-4 h-4 flex-shrink-0" /> <FileTreeItemIcon item={node} />
<span className="text-sm">{node.name}</span> <span className="text-sm">{node.name}</span>
</div> </div>
) )

View file

@ -0,0 +1,34 @@
'use client';
import { FileTreeItem } from "../actions";
import { useMemo } from "react";
import { getIconForFile, getIconForFolder } from "vscode-icons-js";
import { Icon } from '@iconify/react';
import { cn } from "@/lib/utils";
interface FileTreeItemIconProps {
item: FileTreeItem;
className?: string;
}
export const FileTreeItemIcon = ({ item, className }: FileTreeItemIconProps) => {
const iconName = useMemo(() => {
if (item.type === 'tree') {
const icon = getIconForFolder(item.name);
if (icon) {
const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
return iconName;
}
} else if (item.type === 'blob') {
const icon = getIconForFile(item.name);
if (icon) {
const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`;
return iconName;
}
}
return "vscode-icons:file-type-unknown";
}, [item.name, item.type]);
return <Icon icon={iconName} className={cn("w-4 h-4 flex-shrink-0", className)} />;
}

View file

@ -21,6 +21,7 @@ import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { TooltipTrigger } from "@/components/ui/tooltip"; import { TooltipTrigger } from "@/components/ui/tooltip";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { SearchIcon } from "lucide-react";
interface FileTreePanelProps { interface FileTreePanelProps {
@ -103,6 +104,25 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<p className="font-medium">File Tree</p> <p className="font-medium">File Tree</p>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 ml-auto"
onClick={() => {
updateBrowseState({ isFileSearchOpen: true });
}}
>
<SearchIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
<KeyboardShortcutHint shortcut="⌘ P" />
<Separator orientation="vertical" className="h-4" />
<span>Search files</span>
</TooltipContent>
</Tooltip>
</div> </div>
<Separator orientation="horizontal" className="w-full mb-2" /> <Separator orientation="horizontal" className="w-full mb-2" />
{isPending ? ( {isPending ? (