mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +00:00
feature: basic file search (#341)
This commit is contained in:
parent
eb6d58d6d3
commit
37ce151603
10 changed files with 402 additions and 27 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface BrowseState {
|
|||
}
|
||||
isBottomPanelCollapsed: boolean;
|
||||
isFileTreePanelCollapsed: boolean;
|
||||
isFileSearchOpen: boolean;
|
||||
activeExploreMenuTab: "references" | "definitions";
|
||||
bottomPanelSize: number;
|
||||
}
|
||||
|
|
@ -20,6 +21,7 @@ const defaultState: BrowseState = {
|
|||
selectedSymbolInfo: undefined,
|
||||
isBottomPanelCollapsed: true,
|
||||
isFileTreePanelCollapsed: false,
|
||||
isFileSearchOpen: false,
|
||||
activeExploreMenuTab: "references",
|
||||
bottomPanelSize: 35,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -62,6 +63,7 @@ export default function Layout({
|
|||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<FileSearchCommandDialog />
|
||||
</BrowseStateProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,58 @@ export const getFolderContents = async (params: { repoName: string, revisionName
|
|||
|
||||
return contents;
|
||||
}, /* 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 root: FileTreeNode = {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { FileTreeItem } from "../actions";
|
||||
import { useMemo, useEffect, useRef } from "react";
|
||||
import { getIconForFile, getIconForFolder } from "vscode-icons-js";
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
|
||||
import { FileTreeItemIcon } from "./fileTreeItemIcon";
|
||||
|
||||
export const FileTreeItemComponent = ({
|
||||
node,
|
||||
|
|
@ -53,24 +52,6 @@ export const FileTreeItemComponent = ({
|
|||
}
|
||||
}, [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 (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -99,7 +80,7 @@ export const FileTreeItemComponent = ({
|
|||
)
|
||||
)}
|
||||
</div>
|
||||
<Icon icon={iconName} className="w-4 h-4 flex-shrink-0" />
|
||||
<FileTreeItemIcon item={node} />
|
||||
<span className="text-sm">{node.name}</span>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
|||
import { TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
|
||||
interface FileTreePanelProps {
|
||||
|
|
@ -103,6 +104,25 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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>
|
||||
<Separator orientation="horizontal" className="w-full mb-2" />
|
||||
{isPending ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue