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