@tscircuit/fake-snippets 0.0.88 → 0.0.90
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.
- package/api/generated-index.js +96 -14
- package/bun-tests/fake-snippets-api/routes/proxy.test.ts +42 -0
- package/bun.lock +196 -215
- package/dist/bundle.js +596 -370
- package/fake-snippets-api/routes/api/autocomplete/create_autocomplete.ts +134 -0
- package/fake-snippets-api/routes/api/proxy.ts +128 -0
- package/package.json +59 -48
- package/renovate.json +2 -1
- package/src/App.tsx +67 -3
- package/src/ContextProviders.tsx +2 -0
- package/src/build-watcher.ts +52 -0
- package/src/components/CircuitJsonImportDialog.tsx +1 -1
- package/src/components/CmdKMenu.tsx +533 -197
- package/src/components/DownloadButtonAndMenu.tsx +104 -26
- package/src/components/FileSidebar.tsx +11 -1
- package/src/components/Header2.tsx +7 -2
- package/src/components/PackageBuildsPage/LogContent.tsx +25 -22
- package/src/components/PackageBuildsPage/PackageBuildDetailsPage.tsx +6 -6
- package/src/components/PackageBuildsPage/build-preview-content.tsx +5 -5
- package/src/components/PackageBuildsPage/package-build-details-panel.tsx +15 -13
- package/src/components/PackageBuildsPage/package-build-header.tsx +19 -28
- package/src/components/PackageCard.tsx +66 -16
- package/src/components/SearchComponent.tsx +2 -2
- package/src/components/SuspenseRunFrame.tsx +14 -2
- package/src/components/ViewPackagePage/components/important-files-view.tsx +90 -17
- package/src/components/ViewPackagePage/components/main-content-header.tsx +26 -2
- package/src/components/ViewPackagePage/components/mobile-sidebar.tsx +2 -2
- package/src/components/ViewPackagePage/components/repo-page-content.tsx +35 -30
- package/src/components/ViewPackagePage/components/sidebar-about-section.tsx +2 -2
- package/src/components/ViewPackagePage/components/sidebar-releases-section.tsx +20 -12
- package/src/components/ViewPackagePage/components/tab-views/files-view.tsx +0 -7
- package/src/components/ViewPackagePage/utils/fuzz-search.ts +121 -0
- package/src/components/ViewPackagePage/utils/is-hidden-file.ts +4 -0
- package/src/components/dialogs/confirm-delete-package-dialog.tsx +1 -1
- package/src/components/dialogs/confirm-discard-changes-dialog.tsx +73 -0
- package/src/components/dialogs/edit-package-details-dialog.tsx +2 -2
- package/src/components/dialogs/view-ts-files-dialog.tsx +478 -42
- package/src/components/package-port/CodeAndPreview.tsx +17 -16
- package/src/components/package-port/CodeEditor.tsx +138 -17
- package/src/components/package-port/CodeEditorHeader.tsx +44 -4
- package/src/components/package-port/EditorNav.tsx +42 -29
- package/src/components/package-port/GlobalFindReplace.tsx +681 -0
- package/src/components/package-port/QuickOpen.tsx +241 -0
- package/src/components/ui/dialog.tsx +1 -1
- package/src/components/ui/tree-view.tsx +1 -1
- package/src/global.d.ts +3 -0
- package/src/hooks/use-ai-review.ts +31 -0
- package/src/hooks/use-code-completion-ai-api.ts +3 -3
- package/src/hooks/use-current-package-release.ts +5 -1
- package/src/hooks/use-delete-package.ts +6 -2
- package/src/hooks/use-download-zip.ts +50 -0
- package/src/hooks/use-hotkey.ts +116 -0
- package/src/hooks/use-package-by-package-id.ts +1 -0
- package/src/hooks/use-package-by-package-name.ts +1 -0
- package/src/hooks/use-package-files.ts +3 -0
- package/src/hooks/use-package-release.ts +5 -1
- package/src/hooks/use-package.ts +1 -0
- package/src/hooks/use-request-ai-review-mutation.ts +14 -6
- package/src/hooks/use-snippet.ts +1 -0
- package/src/hooks/useFileManagement.ts +28 -10
- package/src/hooks/usePackageFilesLoader.ts +3 -1
- package/src/index.css +11 -0
- package/src/lib/decodeUrlHashToFsMap.ts +17 -0
- package/src/lib/download-fns/download-circuit-png.ts +88 -0
- package/src/lib/download-fns/download-png-utils.ts +31 -0
- package/src/lib/encodeFsMapToUrlHash.ts +13 -0
- package/src/lib/populate-query-cache-with-ssr-data.ts +7 -0
- package/src/lib/ts-lib-cache.ts +47 -0
- package/src/lib/types.ts +2 -0
- package/src/lib/utils/findTargetFile.ts +1 -1
- package/src/lib/utils/package-utils.ts +10 -0
- package/src/main.tsx +7 -0
- package/src/pages/dashboard.tsx +18 -5
- package/src/pages/view-package.tsx +15 -7
- package/src/types/package.ts +4 -0
- 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 "@/types/package"
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
(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
|
+
(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
|