'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(null); const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(''); const { navigateToPath } = useBrowseNavigation(); const [recentlyOpened, setRecentlyOpened] = useLocalStorage(`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 ( { updateBrowseState({ isFileSearchOpen: isOpen, }); }} modal={true} > Search for files {`Search for files in the repository ${repoName}.`} { isLoading ? ( ) : isError ? (

Error loading files.

) : ( {searchQuery.length === 0 ? ( No recently opened files. {recentlyOpened.map((file) => { return ( onSelect(file)} /> ); })} ) : ( <> No results found. {filteredFiles.map(({ file, match }) => { return ( onSelect(file)} /> ); })} {maxResultsHit && (
Maximum results hit. Please refine your search.
)} )}
) }
) } interface SearchResultComponentProps { file: FileTreeItem; match?: { from: number; to: number; }; onSelect: () => void; } const SearchResultComponent = ({ file, match, onSelect, }: SearchResultComponentProps) => { return (
{file.name} {match ? ( ) : ( file.path )}
); } const Highlight = ({ text, range }: { text: string, range: { from: number; to: number } }) => { return ( {text.slice(0, range.from)} {text.slice(range.from, range.to + 1)} {text.slice(range.to + 1)} ) } const ResultsSkeleton = () => { return (
{Array.from({ length: 6 }).map((_, index) => (
))}
); };