@tscircuit/fake-snippets 0.0.87 → 0.0.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/api/generated-index.js +96 -14
  2. package/bun-tests/fake-snippets-api/routes/proxy.test.ts +42 -0
  3. package/bun.lock +187 -206
  4. package/dist/bundle.js +207 -101
  5. package/fake-snippets-api/routes/api/package_releases/create.ts +1 -1
  6. package/fake-snippets-api/routes/api/proxy.ts +128 -0
  7. package/package.json +57 -50
  8. package/renovate.json +2 -1
  9. package/src/App.tsx +22 -3
  10. package/src/ContextProviders.tsx +2 -0
  11. package/src/build-watcher.ts +52 -0
  12. package/src/components/CmdKMenu.tsx +533 -197
  13. package/src/components/DownloadButtonAndMenu.tsx +104 -26
  14. package/src/components/FileSidebar.tsx +11 -1
  15. package/src/components/Header.tsx +5 -1
  16. package/src/components/Header2.tsx +7 -2
  17. package/src/components/HeaderLogin.tsx +1 -1
  18. package/src/components/PackageBuildsPage/LogContent.tsx +25 -22
  19. package/src/components/PackageBuildsPage/PackageBuildDetailsPage.tsx +6 -6
  20. package/src/components/PackageBuildsPage/build-preview-content.tsx +5 -5
  21. package/src/components/PackageBuildsPage/package-build-details-panel.tsx +15 -13
  22. package/src/components/PackageBuildsPage/package-build-header.tsx +17 -16
  23. package/src/components/PackageCard.tsx +66 -16
  24. package/src/components/PrefetchPageLink.tsx +66 -15
  25. package/src/components/SearchComponent.tsx +2 -2
  26. package/src/components/SuspenseRunFrame.tsx +14 -2
  27. package/src/components/ViewPackagePage/components/important-files-view.tsx +97 -22
  28. package/src/components/ViewPackagePage/components/main-content-header.tsx +27 -3
  29. package/src/components/ViewPackagePage/components/mobile-sidebar.tsx +2 -2
  30. package/src/components/ViewPackagePage/components/repo-page-content.tsx +49 -34
  31. package/src/components/ViewPackagePage/components/sidebar-about-section.tsx +2 -2
  32. package/src/components/ViewPackagePage/components/sidebar-releases-section.tsx +20 -12
  33. package/src/components/ViewPackagePage/components/tab-views/files-view.tsx +0 -7
  34. package/src/components/ViewPackagePage/utils/fuzz-search.ts +121 -0
  35. package/src/components/ViewPackagePage/utils/is-hidden-file.ts +4 -0
  36. package/src/components/ViewPackagePage/utils/is-package-file-important.ts +18 -5
  37. package/src/components/dialogs/confirm-delete-package-dialog.tsx +1 -1
  38. package/src/components/dialogs/confirm-discard-changes-dialog.tsx +73 -0
  39. package/src/components/dialogs/edit-package-details-dialog.tsx +2 -2
  40. package/src/components/dialogs/view-ts-files-dialog.tsx +478 -42
  41. package/src/components/package-port/CodeAndPreview.tsx +16 -0
  42. package/src/components/package-port/CodeEditor.tsx +113 -11
  43. package/src/components/package-port/CodeEditorHeader.tsx +39 -4
  44. package/src/components/package-port/EditorNav.tsx +41 -15
  45. package/src/components/package-port/GlobalFindReplace.tsx +681 -0
  46. package/src/components/package-port/QuickOpen.tsx +241 -0
  47. package/src/components/ui/dialog.tsx +1 -1
  48. package/src/components/ui/tree-view.tsx +1 -1
  49. package/src/global.d.ts +3 -0
  50. package/src/hooks/use-ai-review.ts +31 -0
  51. package/src/hooks/use-current-package-release.ts +5 -1
  52. package/src/hooks/use-download-zip.ts +50 -0
  53. package/src/hooks/use-hotkey.ts +116 -0
  54. package/src/hooks/use-package-by-package-id.ts +1 -0
  55. package/src/hooks/use-package-by-package-name.ts +1 -0
  56. package/src/hooks/use-package-files.ts +3 -0
  57. package/src/hooks/use-package-release.ts +5 -1
  58. package/src/hooks/use-package.ts +1 -0
  59. package/src/hooks/use-request-ai-review-mutation.ts +14 -6
  60. package/src/hooks/use-snippet.ts +1 -0
  61. package/src/hooks/useFileManagement.ts +26 -8
  62. package/src/hooks/usePackageFilesLoader.ts +3 -1
  63. package/src/index.css +11 -0
  64. package/src/lib/decodeUrlHashToFsMap.ts +17 -0
  65. package/src/lib/download-fns/download-circuit-png.ts +88 -0
  66. package/src/lib/download-fns/download-png-utils.ts +31 -0
  67. package/src/lib/encodeFsMapToUrlHash.ts +13 -0
  68. package/src/lib/populate-query-cache-with-ssr-data.ts +39 -38
  69. package/src/lib/ts-lib-cache.ts +47 -0
  70. package/src/lib/types.ts +2 -0
  71. package/src/main.tsx +7 -0
  72. package/src/pages/dashboard.tsx +8 -5
  73. package/src/pages/user-profile.tsx +1 -1
  74. package/src/pages/view-package.tsx +15 -7
  75. package/vite.config.ts +100 -1
@@ -0,0 +1,681 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import {
3
+ Replace,
4
+ Code,
5
+ Braces,
6
+ FileIcon,
7
+ BookOpen,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ X,
11
+ Regex,
12
+ CaseSensitive,
13
+ WholeWord,
14
+ RefreshCw,
15
+ } from "lucide-react"
16
+ import { Button } from "@/components/ui/button"
17
+ import { Input } from "@/components/ui/input"
18
+ import { ScrollArea } from "@/components/ui/scroll-area"
19
+ import { Badge } from "@/components/ui/badge"
20
+ import {
21
+ Tooltip,
22
+ TooltipContent,
23
+ TooltipProvider,
24
+ TooltipTrigger,
25
+ } from "@/components/ui/tooltip"
26
+ import type { PackageFile } from "./CodeAndPreview"
27
+ import { isHiddenFile } from "../ViewPackagePage/utils/is-hidden-file"
28
+
29
+ interface Match {
30
+ line: number
31
+ column: number
32
+ text: string
33
+ lineText: string
34
+ startIndex: number
35
+ endIndex: number
36
+ }
37
+
38
+ interface FileMatch {
39
+ file: PackageFile
40
+ matches: Match[]
41
+ isExpanded: boolean
42
+ }
43
+
44
+ interface GlobalFindReplaceProps {
45
+ files: PackageFile[]
46
+ currentFile: string | null
47
+ onFileSelect: (path: string) => void
48
+ onFileContentChanged?: (path: string, content: string) => void
49
+ onClose: () => void
50
+ }
51
+
52
+ const GlobalFindReplace = ({
53
+ files,
54
+ currentFile,
55
+ onFileSelect,
56
+ onFileContentChanged,
57
+ onClose,
58
+ }: GlobalFindReplaceProps) => {
59
+ const [searchQuery, setSearchQuery] = useState("")
60
+ const [replaceQuery, setReplaceQuery] = useState("")
61
+ const [caseSensitive, setCaseSensitive] = useState(false)
62
+ const [wholeWord, setWholeWord] = useState(false)
63
+ const [useRegex, setUseRegex] = useState(false)
64
+ const [showReplace, setShowReplace] = useState(true)
65
+ const [fileMatches, setFileMatches] = useState<FileMatch[]>([])
66
+ const [isSearching, setIsSearching] = useState(false)
67
+
68
+ const searchInputRef = useRef<HTMLInputElement>(null)
69
+ const replaceInputRef = useRef<HTMLInputElement>(null)
70
+
71
+ const totalMatches = useMemo(() => {
72
+ return fileMatches.reduce(
73
+ (total, fileMatch) => total + fileMatch.matches.length,
74
+ 0,
75
+ )
76
+ }, [fileMatches])
77
+
78
+ const searchFiles = useCallback(() => {
79
+ if (!searchQuery) {
80
+ setFileMatches([])
81
+ return
82
+ }
83
+
84
+ setIsSearching(true)
85
+
86
+ const results: FileMatch[] = []
87
+
88
+ files.forEach((file) => {
89
+ if (isHiddenFile(file?.path)) {
90
+ return
91
+ }
92
+
93
+ const matches: Match[] = []
94
+ const content = file.content
95
+ const lines = content.split("\n")
96
+
97
+ let searchRegex: RegExp
98
+ try {
99
+ if (useRegex) {
100
+ searchRegex = new RegExp(searchQuery, caseSensitive ? "g" : "gi")
101
+ } else {
102
+ const escapedQuery = searchQuery.replace(
103
+ /[.*+?^${}()|[\]\\]/g,
104
+ "\\$&",
105
+ )
106
+ const pattern = wholeWord ? `\\b${escapedQuery}\\b` : escapedQuery
107
+ searchRegex = new RegExp(pattern, caseSensitive ? "g" : "gi")
108
+ }
109
+ } catch (error) {
110
+ setIsSearching(false)
111
+ return
112
+ }
113
+
114
+ lines.forEach((lineText, lineIndex) => {
115
+ let match
116
+ while ((match = searchRegex.exec(lineText)) !== null) {
117
+ matches.push({
118
+ line: lineIndex + 1,
119
+ column: match.index + 1,
120
+ text: match[0],
121
+ lineText: lineText,
122
+ startIndex: match.index,
123
+ endIndex: match.index + match[0].length,
124
+ })
125
+ if (!searchRegex.global) break
126
+ }
127
+ })
128
+
129
+ if (matches.length > 0) {
130
+ results.push({
131
+ file,
132
+ matches,
133
+ isExpanded: true,
134
+ })
135
+ }
136
+ })
137
+
138
+ setFileMatches(results)
139
+ setIsSearching(false)
140
+ }, [searchQuery, files, caseSensitive, wholeWord, useRegex])
141
+
142
+ const replaceInFile = useCallback(
143
+ (fileMatch: FileMatch, matchIndex?: number, shouldClose = false) => {
144
+ const { file, matches } = fileMatch
145
+ let newContent = file.content
146
+
147
+ if (matchIndex !== undefined) {
148
+ const match = matches[matchIndex]
149
+ const lines = newContent.split("\n")
150
+ const lineText = lines[match.line - 1]
151
+ const newLineText =
152
+ lineText.substring(0, match.startIndex) +
153
+ replaceQuery +
154
+ lineText.substring(match.endIndex)
155
+ lines[match.line - 1] = newLineText
156
+ newContent = lines.join("\n")
157
+ } else {
158
+ let searchRegex: RegExp
159
+ try {
160
+ if (useRegex) {
161
+ searchRegex = new RegExp(searchQuery, caseSensitive ? "g" : "gi")
162
+ } else {
163
+ const escapedQuery = searchQuery.replace(
164
+ /[.*+?^${}()|[\]\\]/g,
165
+ "\\$&",
166
+ )
167
+ const pattern = wholeWord ? `\\b${escapedQuery}\\b` : escapedQuery
168
+ searchRegex = new RegExp(pattern, caseSensitive ? "g" : "gi")
169
+ }
170
+ newContent = newContent.replace(searchRegex, replaceQuery)
171
+ } catch (error) {
172
+ return
173
+ }
174
+ }
175
+
176
+ file.content = newContent
177
+ onFileContentChanged?.(file.path, newContent)
178
+
179
+ setTimeout(() => {
180
+ searchFiles()
181
+ }, 100)
182
+
183
+ if (shouldClose) {
184
+ setTimeout(() => {
185
+ onClose()
186
+ }, 500)
187
+ }
188
+ },
189
+ [
190
+ replaceQuery,
191
+ searchQuery,
192
+ caseSensitive,
193
+ wholeWord,
194
+ useRegex,
195
+ onFileContentChanged,
196
+ searchFiles,
197
+ onClose,
198
+ ],
199
+ )
200
+
201
+ const replaceAll = useCallback(() => {
202
+ fileMatches.forEach((fileMatch, index) => {
203
+ const shouldClose = index === fileMatches.length - 1
204
+ replaceInFile(fileMatch, undefined, shouldClose)
205
+ })
206
+ }, [fileMatches, replaceInFile])
207
+
208
+ const toggleFileExpansion = useCallback((index: number) => {
209
+ setFileMatches((prev) =>
210
+ prev.map((fileMatch, i) =>
211
+ i === index
212
+ ? { ...fileMatch, isExpanded: !fileMatch.isExpanded }
213
+ : fileMatch,
214
+ ),
215
+ )
216
+ }, [])
217
+
218
+ const goToMatch = useCallback(
219
+ (fileMatch: FileMatch, match: Match) => {
220
+ onFileSelect(fileMatch.file.path)
221
+ onClose()
222
+ },
223
+ [onFileSelect, onClose],
224
+ )
225
+
226
+ const getFileIcon = useCallback((path: string) => {
227
+ const ext = path.split(".").pop()?.toLowerCase()
228
+ const iconProps = { size: 16, className: "" }
229
+
230
+ switch (ext) {
231
+ case "tsx":
232
+ iconProps.className = "text-blue-500"
233
+ return <Code {...iconProps} />
234
+ case "ts":
235
+ iconProps.className = "text-blue-600"
236
+ return <Code {...iconProps} />
237
+ case "jsx":
238
+ iconProps.className = "text-cyan-500"
239
+ return <Code {...iconProps} />
240
+ case "js":
241
+ iconProps.className = "text-yellow-500"
242
+ return <Code {...iconProps} />
243
+ case "json":
244
+ iconProps.className = "text-orange-500"
245
+ return <Braces {...iconProps} />
246
+ case "md":
247
+ iconProps.className = "text-gray-600"
248
+ return <BookOpen {...iconProps} />
249
+ default:
250
+ iconProps.className = "text-gray-400"
251
+ return <FileIcon {...iconProps} />
252
+ }
253
+ }, [])
254
+
255
+ const highlightMatch = useCallback((lineText: string, match: Match) => {
256
+ const before = lineText.substring(0, match.startIndex)
257
+ const matchText = lineText.substring(match.startIndex, match.endIndex)
258
+ const after = lineText.substring(match.endIndex)
259
+
260
+ const renderMatchText = (text: string) => {
261
+ const renderChar = (char: string, index: number) => {
262
+ if (char === " ") {
263
+ return (
264
+ <span
265
+ key={index}
266
+ className="bg-blue-200 text-blue-800 relative inline-block min-w-[8px]"
267
+ >
268
+ <span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold opacity-60">
269
+
270
+ </span>
271
+ &nbsp;
272
+ </span>
273
+ )
274
+ } else if (char === "\t") {
275
+ return (
276
+ <span
277
+ key={index}
278
+ className="bg-blue-200 text-blue-800 relative inline-block min-w-[16px]"
279
+ >
280
+ <span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold opacity-60">
281
+
282
+ </span>
283
+ &nbsp;&nbsp;
284
+ </span>
285
+ )
286
+ } else if (char === "\n") {
287
+ return (
288
+ <span
289
+ key={index}
290
+ className="bg-blue-200 text-blue-800 relative inline-block min-w-[12px]"
291
+ >
292
+ <span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold opacity-60">
293
+
294
+ </span>
295
+ &nbsp;
296
+ </span>
297
+ )
298
+ } else {
299
+ return (
300
+ <span key={index} className="bg-blue-200 text-blue-800">
301
+ {char}
302
+ </span>
303
+ )
304
+ }
305
+ }
306
+
307
+ if (text.length === 1) {
308
+ return renderChar(text, 0)
309
+ } else if (/\s/.test(text)) {
310
+ return text.split("").map((char, i) => renderChar(char, i))
311
+ } else {
312
+ return (
313
+ <span className="bg-blue-200 text-blue-800 px-0.5 rounded">
314
+ {text}
315
+ </span>
316
+ )
317
+ }
318
+ }
319
+
320
+ return (
321
+ <>
322
+ {before}
323
+ {renderMatchText(matchText)}
324
+ {after}
325
+ </>
326
+ )
327
+ }, [])
328
+
329
+ useEffect(() => {
330
+ const timeoutId = setTimeout(() => {
331
+ if (searchQuery) {
332
+ searchFiles()
333
+ } else {
334
+ setFileMatches([])
335
+ }
336
+ }, 300)
337
+
338
+ return () => clearTimeout(timeoutId)
339
+ }, [searchQuery, caseSensitive, wholeWord, useRegex, searchFiles])
340
+
341
+ useEffect(() => {
342
+ searchInputRef.current?.focus()
343
+ }, [])
344
+
345
+ const handleKeyDown = useCallback(
346
+ (e: React.KeyboardEvent) => {
347
+ if (e.key === "Escape") {
348
+ e.preventDefault()
349
+ onClose()
350
+ } else if (e.key === "Enter" && e.ctrlKey) {
351
+ e.preventDefault()
352
+ if (showReplace) {
353
+ replaceAll()
354
+ }
355
+ } else if (e.key === "Tab" && e.shiftKey) {
356
+ e.preventDefault()
357
+ setShowReplace(!showReplace)
358
+ }
359
+ },
360
+ [onClose, showReplace, replaceQuery, replaceAll],
361
+ )
362
+
363
+ return (
364
+ <TooltipProvider>
365
+ <div
366
+ className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-start justify-center pt-4 sm:pt-8 z-[120] p-2 sm:p-4"
367
+ onClick={(e) => {
368
+ if (e.target === e.currentTarget) {
369
+ onClose()
370
+ }
371
+ }}
372
+ >
373
+ <div
374
+ className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl border border-slate-200/50 w-full max-w-3xl mx-auto max-h-[95vh] sm:max-h-[90vh] flex flex-col"
375
+ onClick={(e) => e.stopPropagation()}
376
+ onKeyDown={handleKeyDown}
377
+ >
378
+ <div className="p-4 border-b border-slate-100 space-y-3">
379
+ <div className="flex items-center justify-between">
380
+ <h2 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
381
+ Find and Replace
382
+ </h2>
383
+ <Tooltip>
384
+ <TooltipTrigger asChild>
385
+ <Button variant="ghost" size="icon" onClick={onClose}>
386
+ <X size={16} />
387
+ </Button>
388
+ </TooltipTrigger>
389
+ <TooltipContent>
390
+ <p>Close find and replace (Esc)</p>
391
+ </TooltipContent>
392
+ </Tooltip>
393
+ </div>
394
+
395
+ <div className="space-y-2">
396
+ <div className="flex flex-col sm:flex-row gap-2">
397
+ <div className="flex-1 relative">
398
+ <Input
399
+ ref={searchInputRef}
400
+ value={searchQuery}
401
+ onChange={(e) => setSearchQuery(e.target.value)}
402
+ placeholder="Search across files... (supports whitespace)"
403
+ className="font-mono text-sm pr-8"
404
+ />
405
+ {searchQuery && /^\s+$/.test(searchQuery) && (
406
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-500 pointer-events-none">
407
+ {searchQuery.length === 1 && searchQuery === " " && "•"}
408
+ {searchQuery.length === 1 && searchQuery === "\t" && "→"}
409
+ {searchQuery.length > 1 && `${searchQuery.length} spaces`}
410
+ </div>
411
+ )}
412
+ </div>
413
+ <div className="flex gap-1 justify-center sm:justify-start">
414
+ <Tooltip>
415
+ <TooltipTrigger asChild>
416
+ <Button
417
+ variant={caseSensitive ? "default" : "outline"}
418
+ size="icon"
419
+ className="h-9 w-9"
420
+ onClick={() => setCaseSensitive(!caseSensitive)}
421
+ >
422
+ <CaseSensitive size={14} />
423
+ </Button>
424
+ </TooltipTrigger>
425
+ <TooltipContent>
426
+ <p>Match case (Aa)</p>
427
+ </TooltipContent>
428
+ </Tooltip>
429
+ <Tooltip>
430
+ <TooltipTrigger asChild>
431
+ <Button
432
+ variant={wholeWord ? "default" : "outline"}
433
+ size="icon"
434
+ className="h-9 w-9"
435
+ onClick={() => setWholeWord(!wholeWord)}
436
+ >
437
+ <WholeWord size={14} />
438
+ </Button>
439
+ </TooltipTrigger>
440
+ <TooltipContent>
441
+ <p>Match whole word</p>
442
+ </TooltipContent>
443
+ </Tooltip>
444
+ <Tooltip>
445
+ <TooltipTrigger asChild>
446
+ <Button
447
+ variant={useRegex ? "default" : "outline"}
448
+ size="icon"
449
+ className="h-9 w-9"
450
+ onClick={() => setUseRegex(!useRegex)}
451
+ >
452
+ <Regex size={14} />
453
+ </Button>
454
+ </TooltipTrigger>
455
+ <TooltipContent>
456
+ <p>Use regular expression (.*)</p>
457
+ </TooltipContent>
458
+ </Tooltip>
459
+ <Tooltip>
460
+ <TooltipTrigger asChild>
461
+ <Button
462
+ variant="outline"
463
+ size="icon"
464
+ className={`h-9 w-9 ${showReplace ? "bg-slate-100" : ""}`}
465
+ onClick={() => setShowReplace(!showReplace)}
466
+ >
467
+ <Replace size={14} />
468
+ </Button>
469
+ </TooltipTrigger>
470
+ <TooltipContent>
471
+ <p>Toggle replace mode</p>
472
+ </TooltipContent>
473
+ </Tooltip>
474
+ </div>
475
+ </div>
476
+
477
+ {showReplace && (
478
+ <div className="flex flex-col sm:flex-row gap-2">
479
+ <div className="flex-1">
480
+ <Input
481
+ ref={replaceInputRef}
482
+ value={replaceQuery}
483
+ onChange={(e) => setReplaceQuery(e.target.value)}
484
+ placeholder="Replace with... (empty = delete)"
485
+ className="font-mono text-sm"
486
+ />
487
+ </div>
488
+ <Tooltip>
489
+ <TooltipTrigger asChild>
490
+ <Button
491
+ onClick={replaceAll}
492
+ disabled={fileMatches.length === 0}
493
+ className="h-9 w-full sm:w-auto"
494
+ >
495
+ Replace All ({`${totalMatches}`})
496
+ </Button>
497
+ </TooltipTrigger>
498
+ <TooltipContent>
499
+ <p>
500
+ Replace all {totalMatches} matches across{" "}
501
+ {fileMatches.length} files
502
+ {searchQuery === " " && " (spaces)"}
503
+ {searchQuery === "\t" && " (tabs)"}
504
+ {/^\s+$/.test(searchQuery) &&
505
+ searchQuery.length > 1 &&
506
+ " (whitespace)"}
507
+ </p>
508
+ </TooltipContent>
509
+ </Tooltip>
510
+ </div>
511
+ )}
512
+ </div>
513
+
514
+ {searchQuery && (
515
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-slate-600">
516
+ <span>
517
+ {isSearching ? (
518
+ <span className="flex items-center gap-2">
519
+ <RefreshCw size={14} className="animate-spin" />
520
+ Searching...
521
+ </span>
522
+ ) : (
523
+ `${totalMatches} results in ${fileMatches.length} files`
524
+ )}
525
+ </span>
526
+ <div className="flex flex-wrap gap-2 text-xs">
527
+ <span>Ctrl+Enter: Replace All</span>
528
+ <span className="hidden sm:inline">•</span>
529
+ <span>Shift+Tab: Toggle Replace</span>
530
+ <span className="hidden sm:inline">•</span>
531
+ <span>Esc: Close</span>
532
+ </div>
533
+ </div>
534
+ )}
535
+ </div>
536
+
537
+ <ScrollArea className="flex-1 max-h-[50vh] sm:max-h-[60vh]">
538
+ <div className="p-4 space-y-2">
539
+ {fileMatches.length === 0 && searchQuery && !isSearching && (
540
+ <div className="text-center py-8 text-slate-500">
541
+ No matches found for "
542
+ {searchQuery === " " ? (
543
+ <span className="bg-slate-200 text-slate-700 px-1 rounded font-mono">
544
+ <span className="text-[10px] opacity-60">•</span>
545
+ &nbsp;(space)
546
+ </span>
547
+ ) : searchQuery === "\t" ? (
548
+ <span className="bg-slate-200 text-slate-700 px-1 rounded font-mono">
549
+ <span className="text-[10px] opacity-60">→</span>
550
+ &nbsp;(tab)
551
+ </span>
552
+ ) : /^\s+$/.test(searchQuery) ? (
553
+ <span className="bg-slate-200 text-slate-700 px-1 rounded font-mono">
554
+ {searchQuery.length} whitespace chars
555
+ </span>
556
+ ) : (
557
+ searchQuery
558
+ )}
559
+ "
560
+ </div>
561
+ )}
562
+
563
+ {fileMatches.map((fileMatch, fileIndex) => (
564
+ <div
565
+ key={fileMatch.file.path}
566
+ className="border border-slate-200 rounded-lg overflow-hidden"
567
+ >
568
+ <div
569
+ className="flex items-center justify-between p-3 bg-slate-50 hover:bg-slate-100 cursor-pointer transition-colors min-h-[48px] sm:min-h-0"
570
+ onClick={() => toggleFileExpansion(fileIndex)}
571
+ >
572
+ <div className="flex items-center gap-3">
573
+ {fileMatch.isExpanded ? (
574
+ <ChevronDown size={16} className="text-slate-400" />
575
+ ) : (
576
+ <ChevronRight size={16} className="text-slate-400" />
577
+ )}
578
+ {getFileIcon(fileMatch.file.path)}
579
+ <span className="font-mono text-sm font-medium">
580
+ {fileMatch.file.path}
581
+ </span>
582
+ <Badge variant="secondary" className="text-xs">
583
+ {fileMatch.matches.length} match
584
+ {fileMatch.matches.length !== 1 ? "es" : ""}
585
+ </Badge>
586
+ </div>
587
+ {showReplace && (
588
+ <Tooltip>
589
+ <TooltipTrigger asChild>
590
+ <Button
591
+ size="sm"
592
+ variant="outline"
593
+ onClick={(e) => {
594
+ e.stopPropagation()
595
+ replaceInFile(fileMatch, undefined, true)
596
+ }}
597
+ disabled={false}
598
+ className="h-8 px-2 text-xs sm:h-6"
599
+ >
600
+ <span className="hidden sm:inline">
601
+ Replace in file
602
+ </span>
603
+ <span className="sm:hidden">Replace</span>
604
+ </Button>
605
+ </TooltipTrigger>
606
+ <TooltipContent>
607
+ <p>
608
+ Replace all {fileMatch.matches.length} matches in{" "}
609
+ {fileMatch.file.path}
610
+ {searchQuery === " " && " (spaces)"}
611
+ {searchQuery === "\t" && " (tabs)"}
612
+ {/^\s+$/.test(searchQuery) &&
613
+ searchQuery.length > 1 &&
614
+ " (whitespace)"}
615
+ </p>
616
+ </TooltipContent>
617
+ </Tooltip>
618
+ )}
619
+ </div>
620
+
621
+ {fileMatch.isExpanded && (
622
+ <div className="border-t border-slate-200">
623
+ {fileMatch.matches.map((match, matchIndex) => (
624
+ <div
625
+ key={`${match.line}-${match.column}-${matchIndex}`}
626
+ className="flex items-center justify-between p-2 hover:bg-slate-50 cursor-pointer group min-h-[48px] sm:min-h-0"
627
+ onClick={() => goToMatch(fileMatch, match)}
628
+ >
629
+ <div className="flex-1 min-w-0">
630
+ <div className="flex items-center gap-2 text-xs text-slate-500 mb-1">
631
+ <span>
632
+ Line {match.line}, Column {match.column}
633
+ </span>
634
+ </div>
635
+ <div className="font-mono text-sm text-slate-800 truncate">
636
+ {highlightMatch(match.lineText, match)}
637
+ </div>
638
+ </div>
639
+ {showReplace && (
640
+ <Tooltip>
641
+ <TooltipTrigger asChild>
642
+ <Button
643
+ size="sm"
644
+ variant="outline"
645
+ onClick={(e) => {
646
+ e.stopPropagation()
647
+ replaceInFile(fileMatch, matchIndex, false)
648
+ }}
649
+ disabled={false}
650
+ className="h-8 px-2 text-xs sm:h-6 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
651
+ >
652
+ Replace
653
+ </Button>
654
+ </TooltipTrigger>
655
+ <TooltipContent>
656
+ <p>
657
+ Replace this match on line {match.line}
658
+ {searchQuery === " " && " (space)"}
659
+ {searchQuery === "\t" && " (tab)"}
660
+ {/^\s+$/.test(searchQuery) &&
661
+ searchQuery.length > 1 &&
662
+ " (whitespace)"}
663
+ </p>
664
+ </TooltipContent>
665
+ </Tooltip>
666
+ )}
667
+ </div>
668
+ ))}
669
+ </div>
670
+ )}
671
+ </div>
672
+ ))}
673
+ </div>
674
+ </ScrollArea>
675
+ </div>
676
+ </div>
677
+ </TooltipProvider>
678
+ )
679
+ }
680
+
681
+ export default GlobalFindReplace