1+ 'use client' ;
2+
3+ import { Command , CommandEmpty , CommandGroup , CommandInput , CommandItem , CommandList } from "@/components/ui/command" ;
4+ import { useState , useRef , useMemo , useEffect , useCallback } from "react" ;
5+ import { useHotkeys } from "react-hotkeys-hook" ;
6+ import { useQuery } from "@tanstack/react-query" ;
7+ import { unwrapServiceError } from "@/lib/utils" ;
8+ import { FileTreeItem , getFiles } from "@/features/fileTree/actions" ;
9+ import { useDomain } from "@/hooks/useDomain" ;
10+ import { Dialog , DialogContent , DialogDescription , DialogTitle } from "@/components/ui/dialog" ;
11+ import { useBrowseNavigation } from "../hooks/useBrowseNavigation" ;
12+ import { useBrowseState } from "../hooks/useBrowseState" ;
13+ import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource" ;
14+ import { useBrowseParams } from "../hooks/useBrowseParams" ;
15+ import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon" ;
16+ import { useLocalStorage } from "usehooks-ts" ;
17+ import { Skeleton } from "@/components/ui/skeleton" ;
18+
19+ const MAX_RESULTS = 100 ;
20+
21+ type SearchResult = {
22+ file : FileTreeItem ;
23+ match ?: {
24+ from : number ;
25+ to : number ;
26+ } ;
27+ }
28+
29+
30+ export const FileSearchCommandDialog = ( ) => {
31+ const { repoName, revisionName } = useBrowseParams ( ) ;
32+ const domain = useDomain ( ) ;
33+ const { state : { isFileSearchOpen } , updateBrowseState } = useBrowseState ( ) ;
34+
35+ const commandListRef = useRef < HTMLDivElement > ( null ) ;
36+ const inputRef = useRef < HTMLInputElement > ( null ) ;
37+ const [ searchQuery , setSearchQuery ] = useState ( '' ) ;
38+ const { navigateToPath } = useBrowseNavigation ( ) ;
39+ const { prefetchFileSource } = usePrefetchFileSource ( ) ;
40+
41+ const [ recentlyOpened , setRecentlyOpened ] = useLocalStorage < FileTreeItem [ ] > ( `recentlyOpenedFiles-${ repoName } ` , [ ] ) ;
42+
43+ useHotkeys ( "mod+p" , ( event ) => {
44+ event . preventDefault ( ) ;
45+ updateBrowseState ( {
46+ isFileSearchOpen : ! isFileSearchOpen ,
47+ } ) ;
48+ } , {
49+ enableOnFormTags : true ,
50+ enableOnContentEditable : true ,
51+ description : "Open File Search" ,
52+ } ) ;
53+
54+ // Whenever we open the dialog, clear the search query
55+ useEffect ( ( ) => {
56+ if ( isFileSearchOpen ) {
57+ setSearchQuery ( '' ) ;
58+ }
59+ } , [ isFileSearchOpen ] ) ;
60+
61+ const { data : files , isLoading, isError } = useQuery ( {
62+ queryKey : [ 'files' , repoName , revisionName , domain ] ,
63+ queryFn : ( ) => unwrapServiceError ( getFiles ( { repoName, revisionName : revisionName ?? 'HEAD' } , domain ) ) ,
64+ enabled : isFileSearchOpen ,
65+ } ) ;
66+
67+ const { filteredFiles, maxResultsHit } = useMemo ( ( ) : { filteredFiles : SearchResult [ ] ; maxResultsHit : boolean } => {
68+ if ( ! files || isLoading ) {
69+ return {
70+ filteredFiles : [ ] ,
71+ maxResultsHit : false ,
72+ } ;
73+ }
74+
75+ const matches = files
76+ . map ( ( file ) => {
77+ return {
78+ file,
79+ matchIndex : file . path . toLowerCase ( ) . indexOf ( searchQuery . toLowerCase ( ) ) ,
80+ }
81+ } )
82+ . filter ( ( { matchIndex } ) => {
83+ return matchIndex !== - 1 ;
84+ } ) ;
85+
86+ return {
87+ filteredFiles : matches
88+ . slice ( 0 , MAX_RESULTS )
89+ . map ( ( { file, matchIndex } ) => {
90+ return {
91+ file,
92+ match : {
93+ from : matchIndex ,
94+ to : matchIndex + searchQuery . length - 1 ,
95+ } ,
96+ }
97+ } ) ,
98+ maxResultsHit : matches . length > MAX_RESULTS ,
99+ }
100+ } , [ searchQuery , files , isLoading ] ) ;
101+
102+ // Scroll to the top of the list whenever the search query changes
103+ useEffect ( ( ) => {
104+ commandListRef . current ?. scrollTo ( {
105+ top : 0 ,
106+ } )
107+ } , [ searchQuery ] ) ;
108+
109+ const onSelect = useCallback ( ( file : FileTreeItem ) => {
110+ setRecentlyOpened ( ( prev ) => {
111+ const filtered = prev . filter ( f => f . path !== file . path ) ;
112+ return [ file , ...filtered ] ;
113+ } ) ;
114+ navigateToPath ( {
115+ repoName,
116+ revisionName,
117+ path : file . path ,
118+ pathType : 'blob' ,
119+ } ) ;
120+ updateBrowseState ( {
121+ isFileSearchOpen : false ,
122+ } ) ;
123+ } , [ navigateToPath , repoName , revisionName , setRecentlyOpened , updateBrowseState ] ) ;
124+
125+ const onMouseEnter = useCallback ( ( file : FileTreeItem ) => {
126+ prefetchFileSource (
127+ repoName ,
128+ revisionName ?? 'HEAD' ,
129+ file . path
130+ ) ;
131+ } , [ prefetchFileSource , repoName , revisionName ] ) ;
132+
133+ // @note : We were hitting issues when the user types into the input field while the files are still
134+ // loading. The workaround was to set `disabled` when loading and then focus the input field when
135+ // the files are loaded, hence the `useEffect` below.
136+ useEffect ( ( ) => {
137+ if ( ! isLoading ) {
138+ inputRef . current ?. focus ( ) ;
139+ }
140+ } , [ isLoading ] ) ;
141+
142+ return (
143+ < Dialog
144+ open = { isFileSearchOpen }
145+ onOpenChange = { ( isOpen ) => {
146+ updateBrowseState ( {
147+ isFileSearchOpen : isOpen ,
148+ } ) ;
149+ } }
150+ modal = { true }
151+ >
152+ < DialogContent
153+ className = "overflow-hidden p-0 shadow-lg max-w-[90vw] sm:max-w-2xl top-[20%] translate-y-0"
154+ >
155+ < DialogTitle className = "sr-only" > Search for files</ DialogTitle >
156+ < DialogDescription className = "sr-only" > { `Search for files in the repository ${ repoName } .` } </ DialogDescription >
157+ < Command
158+ shouldFilter = { false }
159+ >
160+ < CommandInput
161+ placeholder = { `Search for files in ${ repoName } ...` }
162+ onValueChange = { setSearchQuery }
163+ disabled = { isLoading }
164+ ref = { inputRef }
165+ />
166+ {
167+ isLoading ? (
168+ < ResultsSkeleton />
169+ ) : isError ? (
170+ < p > Error loading files.</ p >
171+ ) : (
172+ < CommandList ref = { commandListRef } >
173+ { searchQuery . length === 0 ? (
174+ < CommandGroup
175+ heading = "Recently opened"
176+ >
177+ < CommandEmpty className = "text-muted-foreground text-center text-sm py-6" > No recently opened files.</ CommandEmpty >
178+ { recentlyOpened . map ( ( file ) => {
179+ return (
180+ < SearchResultComponent
181+ key = { file . path }
182+ file = { file }
183+ onSelect = { ( ) => onSelect ( file ) }
184+ onMouseEnter = { ( ) => onMouseEnter ( file ) }
185+ />
186+ ) ;
187+ } ) }
188+ </ CommandGroup >
189+ ) : (
190+ < >
191+ < CommandEmpty className = "text-muted-foreground text-center text-sm py-6" > No results found.</ CommandEmpty >
192+ { filteredFiles . map ( ( { file, match } ) => {
193+ return (
194+ < SearchResultComponent
195+ key = { file . path }
196+ file = { file }
197+ match = { match }
198+ onSelect = { ( ) => onSelect ( file ) }
199+ onMouseEnter = { ( ) => onMouseEnter ( file ) }
200+ />
201+ ) ;
202+ } ) }
203+ { maxResultsHit && (
204+ < div className = "text-muted-foreground text-center text-sm py-4" >
205+ Maximum results hit. Please refine your search.
206+ </ div >
207+ ) }
208+ </ >
209+ ) }
210+ </ CommandList >
211+ )
212+ }
213+ </ Command >
214+ </ DialogContent >
215+ </ Dialog >
216+ )
217+ }
218+
219+ interface SearchResultComponentProps {
220+ file : FileTreeItem ;
221+ match ?: {
222+ from : number ;
223+ to : number ;
224+ } ;
225+ onSelect : ( ) => void ;
226+ onMouseEnter : ( ) => void ;
227+ }
228+
229+ const SearchResultComponent = ( {
230+ file,
231+ match,
232+ onSelect,
233+ onMouseEnter,
234+ } : SearchResultComponentProps ) => {
235+ return (
236+ < CommandItem
237+ key = { file . path }
238+ onSelect = { onSelect }
239+ onMouseEnter = { onMouseEnter }
240+ >
241+ < div className = "flex flex-row gap-2 w-full cursor-pointer relative" >
242+ < FileTreeItemIcon item = { file } className = "mt-1" />
243+ < div className = "flex flex-col w-full" >
244+ < span className = "text-sm font-medium" >
245+ { file . name }
246+ </ span >
247+ < span className = "text-xs text-muted-foreground" >
248+ { match ? (
249+ < Highlight text = { file . path } range = { match } />
250+ ) : (
251+ file . path
252+ ) }
253+ </ span >
254+ </ div >
255+ </ div >
256+ </ CommandItem >
257+ ) ;
258+ }
259+
260+ const Highlight = ( { text, range } : { text : string , range : { from : number ; to : number } } ) => {
261+ return (
262+ < span >
263+ { text . slice ( 0 , range . from ) }
264+ < span className = "searchMatch-selected" > { text . slice ( range . from , range . to + 1 ) } </ span >
265+ { text . slice ( range . to + 1 ) }
266+ </ span >
267+ )
268+ }
269+
270+ const ResultsSkeleton = ( ) => {
271+ return (
272+ < div className = "p-2" >
273+ { Array . from ( { length : 6 } ) . map ( ( _ , index ) => (
274+ < div key = { index } className = "flex flex-row gap-2 p-2 mb-1" >
275+ < Skeleton className = "w-4 h-4" />
276+ < div className = "flex flex-col w-full gap-1" >
277+ < Skeleton className = "h-4 w-1/4" />
278+ < Skeleton className = "h-3 w-1/2" />
279+ </ div >
280+ </ div >
281+ ) ) }
282+ </ div >
283+ ) ;
284+ } ;
0 commit comments