sourcebot/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx

268 lines
No EOL
10 KiB
TypeScript

'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 { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
import { useBrowseState } from "../hooks/useBrowseState";
import { useBrowseParams } from "../hooks/useBrowseParams";
import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon";
import { useLocalStorage } from "usehooks-ts";
import { Skeleton } from "@/components/ui/skeleton";
import { FileTreeItem } from "@/features/fileTree/types";
import { getFiles } from "@/app/api/(client)/client";
const MAX_RESULTS = 100;
type SearchResult = {
file: FileTreeItem;
match?: {
from: number;
to: number;
};
}
export const FileSearchCommandDialog = () => {
const { repoName, revisionName } = useBrowseParams();
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
const commandListRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation();
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],
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' })),
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]);
// @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)}
/>
);
})}
</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)}
/>
);
})}
{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;
}
const SearchResultComponent = ({
file,
match,
onSelect,
}: SearchResultComponentProps) => {
return (
<CommandItem
key={file.path}
onSelect={onSelect}
>
<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>
);
};