@lievclouds/dsh-cortex 0.5.4 → 1.0.0
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.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/README.md +1 -1
- package/client.js +434 -412
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -110
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/host/cortex/index.js +42 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/tools.js +2 -0
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
|
@@ -0,0 +1,1493 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { FormEvent, UIEvent as ReactUIEvent } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Archive,
|
|
5
|
+
Check,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronUp,
|
|
8
|
+
Copy,
|
|
9
|
+
Download,
|
|
10
|
+
FolderOpen,
|
|
11
|
+
Loader2,
|
|
12
|
+
Pencil,
|
|
13
|
+
ScanSearch,
|
|
14
|
+
Search,
|
|
15
|
+
Unplug,
|
|
16
|
+
X
|
|
17
|
+
} from 'lucide-react'
|
|
18
|
+
import { toast } from 'sonner'
|
|
19
|
+
import { Button } from '@renderer/components/ui/button'
|
|
20
|
+
import { Input } from '@renderer/components/ui/input'
|
|
21
|
+
import { Skeleton } from '@renderer/components/ui/skeleton'
|
|
22
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
|
23
|
+
import {
|
|
24
|
+
Sheet,
|
|
25
|
+
SheetCloseButton,
|
|
26
|
+
SheetContent,
|
|
27
|
+
SheetTitle
|
|
28
|
+
} from '@renderer/components/ui/sheet'
|
|
29
|
+
import { CopyButton } from '@renderer/components/common/CopyButton'
|
|
30
|
+
import { EmptyState } from '@renderer/components/common/EmptyState'
|
|
31
|
+
import { MiniappFormatBadge, MiniappStatusBadge } from '@renderer/components/miniapp/MiniappCard'
|
|
32
|
+
import { FileTree } from '@renderer/components/miniapp/FileTree'
|
|
33
|
+
import { useCopy } from '@renderer/hooks/useCopy'
|
|
34
|
+
import {
|
|
35
|
+
MiniappApiError,
|
|
36
|
+
analyzeMiniapp,
|
|
37
|
+
getMiniapp,
|
|
38
|
+
importMiniappAssets,
|
|
39
|
+
openMiniappDir,
|
|
40
|
+
readMiniappFile,
|
|
41
|
+
renameMiniapp,
|
|
42
|
+
searchMiniappFiles,
|
|
43
|
+
toMiniappErrorMessage,
|
|
44
|
+
unpackMiniapp,
|
|
45
|
+
type MiniappAnalysis,
|
|
46
|
+
type MiniappDetailResponse,
|
|
47
|
+
type MiniappFileResponse,
|
|
48
|
+
type MiniappImportResponse,
|
|
49
|
+
type MiniappSearchResponse
|
|
50
|
+
} from '@renderer/lib/miniappApi'
|
|
51
|
+
import { formatBytes, formatDateTime, formatDateTimeFull } from '@renderer/lib/format'
|
|
52
|
+
import { useI18n } from '@renderer/i18n'
|
|
53
|
+
import { cn } from '@renderer/lib/utils'
|
|
54
|
+
|
|
55
|
+
const PREVIEW_LINES = 2000
|
|
56
|
+
|
|
57
|
+
function escapeHtml(value: string): string {
|
|
58
|
+
return value
|
|
59
|
+
.replace(/&/g, '&')
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/g, ''')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escapeRegExp(value: string): string {
|
|
67
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The analyzer records raw-file line numbers while the preview shows formatted
|
|
72
|
+
* lines, so a raw line number is not reliable after formatting. Search the
|
|
73
|
+
* secret's own value instead; pick the longest single-line chunk to keep the
|
|
74
|
+
* find query short and line-oriented.
|
|
75
|
+
*/
|
|
76
|
+
function secretSearchNeedle(value: string | undefined): string {
|
|
77
|
+
if (typeof value !== 'string') return ''
|
|
78
|
+
const segments = value
|
|
79
|
+
.split(/\r?\n/)
|
|
80
|
+
.map((segment) => segment.trim())
|
|
81
|
+
.filter((segment) => segment.length >= 8)
|
|
82
|
+
.sort((left, right) => right.length - left.length)
|
|
83
|
+
const chosen = segments[0] || value.trim()
|
|
84
|
+
return chosen.slice(0, 180)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build one display-only HTML string with a line-number gutter and <mark> hits. */
|
|
88
|
+
function buildPreviewHtml(
|
|
89
|
+
content: string,
|
|
90
|
+
offset: number,
|
|
91
|
+
query: string,
|
|
92
|
+
regex: boolean,
|
|
93
|
+
activeLine: number | null
|
|
94
|
+
): string {
|
|
95
|
+
const lines = content.split('\n')
|
|
96
|
+
let matcher: RegExp | null = null
|
|
97
|
+
if (query.length > 0) {
|
|
98
|
+
try {
|
|
99
|
+
matcher = new RegExp(regex ? query : escapeRegExp(query), 'gi')
|
|
100
|
+
} catch {
|
|
101
|
+
matcher = null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const markup = lines.map((line, index) => {
|
|
105
|
+
const lineNo = offset + index + 1
|
|
106
|
+
let html = ''
|
|
107
|
+
if (matcher) {
|
|
108
|
+
let last = 0
|
|
109
|
+
matcher.lastIndex = 0
|
|
110
|
+
let match: RegExpExecArray | null
|
|
111
|
+
while ((match = matcher.exec(line)) !== null) {
|
|
112
|
+
if (match[0].length === 0) {
|
|
113
|
+
matcher.lastIndex += 1
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
html += escapeHtml(line.slice(last, match.index))
|
|
117
|
+
html += `<mark style="background:#fde68a;color:inherit;border-radius:2px;padding:0 1px">${escapeHtml(match[0])}</mark>`
|
|
118
|
+
last = match.index + match[0].length
|
|
119
|
+
}
|
|
120
|
+
html += escapeHtml(line.slice(last))
|
|
121
|
+
} else {
|
|
122
|
+
html = escapeHtml(line)
|
|
123
|
+
}
|
|
124
|
+
const active = lineNo === activeLine
|
|
125
|
+
const background = active ? '#fff7cc' : 'transparent'
|
|
126
|
+
const style = `display:flex;min-height:1.65em;background:${background};`
|
|
127
|
+
const numberStyle = `position:sticky;left:0;flex:0 0 auto;width:4.6em;padding-right:0.8em;text-align:right;color:#94a3b8;background:${background};user-select:none;`
|
|
128
|
+
return `<div data-line="${lineNo}" style="${style}"><span style="${numberStyle}">${lineNo}</span><span style="white-space:pre;flex:1 1 auto">${html.length > 0 ? html : ' '}</span></div>`
|
|
129
|
+
})
|
|
130
|
+
return markup.join('')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type DetailTab = 'overview' | 'files' | 'search'
|
|
134
|
+
type DetailAction = 'unpack' | 'analyze' | 'import' | 'open'
|
|
135
|
+
type ExtractedCategory = 'pages' | 'subPackages' | 'domains' | 'endpoints' | 'secrets' | 'parameters'
|
|
136
|
+
|
|
137
|
+
interface MiniappDetailProps {
|
|
138
|
+
miniappId: string | null
|
|
139
|
+
onClose: () => void
|
|
140
|
+
/** Notify the page that list-level fields (status/has_analysis) changed. */
|
|
141
|
+
onChanged: () => void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function countOf(analysis: MiniappAnalysis | null, key: 'pages' | 'subPackages' | 'domains' | 'endpoints' | 'secrets' | 'parameters'): number {
|
|
145
|
+
if (analysis === null) return 0
|
|
146
|
+
const value = analysis[key]
|
|
147
|
+
return Array.isArray(value) ? value.length : 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Right-hand inspector for one mini-program source: extracted overview, lazy
|
|
152
|
+
* source tree + text preview and regex/glob search over the unpacked files.
|
|
153
|
+
* Everything is read-only; the only mutations are the explicit unpack /
|
|
154
|
+
* analyze / import / open actions.
|
|
155
|
+
*/
|
|
156
|
+
export function MiniappDetail({ miniappId, onClose, onChanged }: MiniappDetailProps) {
|
|
157
|
+
const { t } = useI18n()
|
|
158
|
+
const { copy } = useCopy()
|
|
159
|
+
const [detail, setDetail] = useState<MiniappDetailResponse | null>(null)
|
|
160
|
+
const [loading, setLoading] = useState(false)
|
|
161
|
+
const [error, setError] = useState<string | null>(null)
|
|
162
|
+
const [retrySeq, setRetrySeq] = useState(0)
|
|
163
|
+
const [tab, setTab] = useState<DetailTab>('overview')
|
|
164
|
+
const [action, setAction] = useState<DetailAction | null>(null)
|
|
165
|
+
const [importResult, setImportResult] = useState<MiniappImportResponse | null>(null)
|
|
166
|
+
const [overviewCategory, setOverviewCategory] = useState<ExtractedCategory>('domains')
|
|
167
|
+
const [renaming, setRenaming] = useState(false)
|
|
168
|
+
const [nameDraft, setNameDraft] = useState('')
|
|
169
|
+
const [renamePending, setRenamePending] = useState(false)
|
|
170
|
+
|
|
171
|
+
const [preview, setPreview] = useState<MiniappFileResponse | null>(null)
|
|
172
|
+
const [previewPath, setPreviewPath] = useState<string | null>(null)
|
|
173
|
+
const [previewLoading, setPreviewLoading] = useState(false)
|
|
174
|
+
const [previewLoadingMore, setPreviewLoadingMore] = useState(false)
|
|
175
|
+
const [fileFindInput, setFileFindInput] = useState('')
|
|
176
|
+
const [fileFind, setFileFind] = useState('')
|
|
177
|
+
const [fileFindRegex, setFileFindRegex] = useState(false)
|
|
178
|
+
const [activeLine, setActiveLine] = useState<number | null>(null)
|
|
179
|
+
const [activeMatchIndex, setActiveMatchIndex] = useState(-1)
|
|
180
|
+
const previewScrollRef = useRef<HTMLDivElement | null>(null)
|
|
181
|
+
const [previewError, setPreviewError] = useState<string | null>(null)
|
|
182
|
+
|
|
183
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
184
|
+
const [searchRegex, setSearchRegex] = useState(false)
|
|
185
|
+
const [searchGlob, setSearchGlob] = useState('')
|
|
186
|
+
const [searchMax, setSearchMax] = useState('50')
|
|
187
|
+
const [searchResults, setSearchResults] = useState<MiniappSearchResponse | null>(null)
|
|
188
|
+
const [searching, setSearching] = useState(false)
|
|
189
|
+
const [searchError, setSearchError] = useState<string | null>(null)
|
|
190
|
+
|
|
191
|
+
// New source (or reopening the same one) starts from a clean inspector.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
setDetail(null)
|
|
194
|
+
setError(null)
|
|
195
|
+
setTab('overview')
|
|
196
|
+
setAction(null)
|
|
197
|
+
setImportResult(null)
|
|
198
|
+
setPreview(null)
|
|
199
|
+
setPreviewPath(null)
|
|
200
|
+
setPreviewError(null)
|
|
201
|
+
setPreviewLoading(false)
|
|
202
|
+
setPreviewLoadingMore(false)
|
|
203
|
+
setFileFindInput('')
|
|
204
|
+
setFileFind('')
|
|
205
|
+
setFileFindRegex(false)
|
|
206
|
+
setActiveLine(null)
|
|
207
|
+
setActiveMatchIndex(-1)
|
|
208
|
+
setOverviewCategory('domains')
|
|
209
|
+
setSearchQuery('')
|
|
210
|
+
setSearchRegex(false)
|
|
211
|
+
setSearchGlob('')
|
|
212
|
+
setSearchMax('50')
|
|
213
|
+
setSearchResults(null)
|
|
214
|
+
setSearchError(null)
|
|
215
|
+
setRenaming(false)
|
|
216
|
+
setNameDraft('')
|
|
217
|
+
setRenamePending(false)
|
|
218
|
+
}, [miniappId])
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (miniappId === null) {
|
|
222
|
+
setLoading(false)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
let alive = true
|
|
226
|
+
setLoading(true)
|
|
227
|
+
setError(null)
|
|
228
|
+
setDetail(null)
|
|
229
|
+
getMiniapp(miniappId).then(
|
|
230
|
+
(result) => {
|
|
231
|
+
if (!alive) return
|
|
232
|
+
setDetail(result)
|
|
233
|
+
setTab(result.miniapp.status === 'unpacked' ? 'files' : 'overview')
|
|
234
|
+
setLoading(false)
|
|
235
|
+
},
|
|
236
|
+
(cause: unknown) => {
|
|
237
|
+
if (!alive) return
|
|
238
|
+
setError(toMiniappErrorMessage(cause))
|
|
239
|
+
setLoading(false)
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return () => {
|
|
243
|
+
alive = false
|
|
244
|
+
}
|
|
245
|
+
}, [miniappId, retrySeq])
|
|
246
|
+
|
|
247
|
+
const loadFile = useCallback(
|
|
248
|
+
async (
|
|
249
|
+
path: string,
|
|
250
|
+
offset: number,
|
|
251
|
+
append = false,
|
|
252
|
+
options: {
|
|
253
|
+
find?: string
|
|
254
|
+
findRegex?: boolean
|
|
255
|
+
scrollLine?: number | null
|
|
256
|
+
hideFindInput?: boolean
|
|
257
|
+
} = {}
|
|
258
|
+
) => {
|
|
259
|
+
if (miniappId === null) return
|
|
260
|
+
const findQuery = options.find !== undefined ? options.find : fileFind
|
|
261
|
+
const findRegexValue =
|
|
262
|
+
options.findRegex !== undefined ? options.findRegex : fileFindRegex
|
|
263
|
+
const scrollLine = options.scrollLine ?? null
|
|
264
|
+
if (!append) {
|
|
265
|
+
setFileFind(findQuery)
|
|
266
|
+
setFileFindRegex(findRegexValue)
|
|
267
|
+
setFileFindInput(options.hideFindInput ? '' : findQuery)
|
|
268
|
+
setActiveMatchIndex(-1)
|
|
269
|
+
if (scrollLine === null) setActiveLine(null)
|
|
270
|
+
}
|
|
271
|
+
setPreviewPath(path)
|
|
272
|
+
if (append) setPreviewLoadingMore(true)
|
|
273
|
+
else setPreviewLoading(true)
|
|
274
|
+
setPreviewError(null)
|
|
275
|
+
try {
|
|
276
|
+
let result = await readMiniappFile(
|
|
277
|
+
miniappId,
|
|
278
|
+
path,
|
|
279
|
+
offset,
|
|
280
|
+
PREVIEW_LINES,
|
|
281
|
+
1024 * 1024,
|
|
282
|
+
findQuery,
|
|
283
|
+
findRegexValue
|
|
284
|
+
)
|
|
285
|
+
let targetLine = scrollLine
|
|
286
|
+
if (targetLine === null && findQuery.length > 0 && (result.matches?.length ?? 0) > 0) {
|
|
287
|
+
targetLine = result.matches![0].line
|
|
288
|
+
}
|
|
289
|
+
const pageStart = result.offset + 1
|
|
290
|
+
const pageEnd = result.offset + (result.loadedLines ?? result.content.split('\n').length)
|
|
291
|
+
if (!append && targetLine !== null && (targetLine < pageStart || targetLine > pageEnd)) {
|
|
292
|
+
const chunkOffset = Math.floor((targetLine - 1) / PREVIEW_LINES) * PREVIEW_LINES
|
|
293
|
+
if (chunkOffset !== offset) {
|
|
294
|
+
result = await readMiniappFile(
|
|
295
|
+
miniappId,
|
|
296
|
+
path,
|
|
297
|
+
chunkOffset,
|
|
298
|
+
PREVIEW_LINES,
|
|
299
|
+
1024 * 1024,
|
|
300
|
+
findQuery,
|
|
301
|
+
findRegexValue
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (append) {
|
|
306
|
+
setPreview((current) => {
|
|
307
|
+
if (current === null || current.path !== path) return result
|
|
308
|
+
return {
|
|
309
|
+
...result,
|
|
310
|
+
offset: current.offset,
|
|
311
|
+
content: `${current.content}\n${result.content}`,
|
|
312
|
+
loadedLines:
|
|
313
|
+
(current.loadedLines ?? current.content.split('\n').length) +
|
|
314
|
+
(result.loadedLines ?? result.content.split('\n').length)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
} else {
|
|
318
|
+
setPreview(result)
|
|
319
|
+
if (targetLine !== null) {
|
|
320
|
+
const matches = result.matches ?? []
|
|
321
|
+
const matchIndex = matches.findIndex((match) => match.line === targetLine)
|
|
322
|
+
setActiveMatchIndex(matchIndex)
|
|
323
|
+
setActiveLine(targetLine)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (result.binary) setPreviewError(t('miniapp.file.binary'))
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
if (!append) setPreview(null)
|
|
329
|
+
setPreviewError(
|
|
330
|
+
cause instanceof MiniappApiError && cause.code === 'BINARY_FILE'
|
|
331
|
+
? t('miniapp.file.binary')
|
|
332
|
+
: t('miniapp.file.loadError', { error: toMiniappErrorMessage(cause) })
|
|
333
|
+
)
|
|
334
|
+
} finally {
|
|
335
|
+
setPreviewLoading(false)
|
|
336
|
+
setPreviewLoadingMore(false)
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
[fileFind, fileFindRegex, miniappId, t]
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const jumpToMatch = (delta: number): void => {
|
|
343
|
+
if (!preview) return
|
|
344
|
+
const matches = preview.matches ?? []
|
|
345
|
+
if (matches.length === 0) return
|
|
346
|
+
let next = activeMatchIndex + delta
|
|
347
|
+
if (next < 0) next = matches.length - 1
|
|
348
|
+
if (next >= matches.length) next = 0
|
|
349
|
+
setActiveMatchIndex(next)
|
|
350
|
+
const line = matches[next].line
|
|
351
|
+
const pageStart = (preview.offset ?? 0) + 1
|
|
352
|
+
const pageEnd = pageStart + (preview.loadedLines ?? 0) - 1
|
|
353
|
+
if (line < pageStart || line > pageEnd) {
|
|
354
|
+
void loadFile(
|
|
355
|
+
preview.path,
|
|
356
|
+
Math.floor((line - 1) / PREVIEW_LINES) * PREVIEW_LINES,
|
|
357
|
+
false,
|
|
358
|
+
{ find: fileFind, findRegex: fileFindRegex, scrollLine: line }
|
|
359
|
+
)
|
|
360
|
+
} else {
|
|
361
|
+
setActiveLine(line)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const openAnalysisFile = (
|
|
366
|
+
path: string,
|
|
367
|
+
options: { find?: string; line?: number | null; hideFindInput?: boolean } = {}
|
|
368
|
+
): void => {
|
|
369
|
+
const line = options.line ?? null
|
|
370
|
+
setTab('files')
|
|
371
|
+
void loadFile(
|
|
372
|
+
path,
|
|
373
|
+
line !== null ? Math.floor((line - 1) / PREVIEW_LINES) * PREVIEW_LINES : 0,
|
|
374
|
+
false,
|
|
375
|
+
{
|
|
376
|
+
find: options.find ?? '',
|
|
377
|
+
findRegex: false,
|
|
378
|
+
scrollLine: line,
|
|
379
|
+
hideFindInput: options.hideFindInput
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const handleFileFind = (event: FormEvent<HTMLFormElement>): void => { event.preventDefault()
|
|
385
|
+
if (!preview) return
|
|
386
|
+
const query = fileFindInput.trim()
|
|
387
|
+
if (query.length === 0) {
|
|
388
|
+
setFileFind('')
|
|
389
|
+
setActiveLine(null)
|
|
390
|
+
setActiveMatchIndex(-1)
|
|
391
|
+
void loadFile(preview.path, preview.offset ?? 0, false, { find: '', findRegex: false })
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
void loadFile(preview.path, 0, false, { find: query, findRegex: fileFindRegex })
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
if (activeLine === null) return
|
|
399
|
+
const container = previewScrollRef.current
|
|
400
|
+
const target = container?.querySelector(`[data-line="${activeLine}"]`)
|
|
401
|
+
if (target && typeof (target as HTMLElement).scrollIntoView === 'function') {
|
|
402
|
+
;(target as HTMLElement).scrollIntoView({ block: 'center' })
|
|
403
|
+
}
|
|
404
|
+
}, [activeLine, preview?.content])
|
|
405
|
+
|
|
406
|
+
const previewHtml = useMemo(() => {
|
|
407
|
+
if (preview === null || preview.binary) return null
|
|
408
|
+
return buildPreviewHtml(
|
|
409
|
+
preview.content,
|
|
410
|
+
preview.offset ?? 0,
|
|
411
|
+
fileFind,
|
|
412
|
+
fileFindRegex,
|
|
413
|
+
activeLine
|
|
414
|
+
)
|
|
415
|
+
}, [preview, fileFind, fileFindRegex, activeLine])
|
|
416
|
+
|
|
417
|
+
const handlePreviewScroll = (event: ReactUIEvent<HTMLDivElement>): void => {
|
|
418
|
+
const element = event.currentTarget
|
|
419
|
+
if (!preview || previewLoading || previewLoadingMore || preview.binary || !preview.hasMore) return
|
|
420
|
+
if (element.scrollTop + element.clientHeight < element.scrollHeight - 240) return
|
|
421
|
+
void loadFile(preview.path, (preview.offset ?? 0) + (preview.loadedLines ?? 0), true)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const runSearch = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault()
|
|
425
|
+
if (miniappId === null) return
|
|
426
|
+
const query = searchQuery.trim()
|
|
427
|
+
if (query.length === 0) {
|
|
428
|
+
setSearchResults(null)
|
|
429
|
+
setSearchError(null)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
setSearching(true)
|
|
433
|
+
setSearchError(null)
|
|
434
|
+
try {
|
|
435
|
+
const result = await searchMiniappFiles(miniappId, {
|
|
436
|
+
query,
|
|
437
|
+
regex: searchRegex,
|
|
438
|
+
glob: searchGlob.trim(),
|
|
439
|
+
maxResults: Number.parseInt(searchMax, 10) || 50
|
|
440
|
+
})
|
|
441
|
+
setSearchResults(result)
|
|
442
|
+
} catch (cause) {
|
|
443
|
+
setSearchResults(null)
|
|
444
|
+
setSearchError(t('miniapp.search.error', { error: toMiniappErrorMessage(cause) }))
|
|
445
|
+
} finally {
|
|
446
|
+
setSearching(false)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const refreshDetail = (next: MiniappDetailResponse): void => {
|
|
451
|
+
setDetail(next)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const handleAnalyze = async () => {
|
|
455
|
+
if (miniappId === null || detail === null || action !== null) return
|
|
456
|
+
setAction('analyze')
|
|
457
|
+
try {
|
|
458
|
+
const result = await analyzeMiniapp(miniappId)
|
|
459
|
+
refreshDetail({
|
|
460
|
+
...detail,
|
|
461
|
+
analysis: result.analysis,
|
|
462
|
+
overviewText: result.overviewText,
|
|
463
|
+
extractedCounts: result.counts
|
|
464
|
+
})
|
|
465
|
+
setTab('overview')
|
|
466
|
+
toast.success(
|
|
467
|
+
t('miniapp.toast.analyzed', {
|
|
468
|
+
pages: result.analysis?.pages.length ?? 0,
|
|
469
|
+
endpoints: result.counts.endpoints,
|
|
470
|
+
secrets: result.counts.secrets
|
|
471
|
+
})
|
|
472
|
+
)
|
|
473
|
+
onChanged()
|
|
474
|
+
} catch (cause) {
|
|
475
|
+
toast.error(t('miniapp.toast.analyzeFailed', { error: toMiniappErrorMessage(cause) }))
|
|
476
|
+
} finally {
|
|
477
|
+
setAction(null)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const handleImport = async () => {
|
|
482
|
+
if (miniappId === null || detail === null || action !== null) return
|
|
483
|
+
setAction('import')
|
|
484
|
+
try {
|
|
485
|
+
const result = await importMiniappAssets(miniappId, {})
|
|
486
|
+
setImportResult(result)
|
|
487
|
+
if (result.ok) {
|
|
488
|
+
toast.success(t('miniapp.toast.imported', { ...result.totals }))
|
|
489
|
+
} else {
|
|
490
|
+
toast.warning(t('miniapp.toast.importWarnings', { warnings: result.warnings.join('; ') }))
|
|
491
|
+
}
|
|
492
|
+
onChanged()
|
|
493
|
+
} catch (cause) {
|
|
494
|
+
toast.error(t('miniapp.toast.importFailed', { error: toMiniappErrorMessage(cause) }))
|
|
495
|
+
} finally {
|
|
496
|
+
setAction(null)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const handleUnpack = async () => {
|
|
501
|
+
if (miniappId === null || detail === null || action !== null) return
|
|
502
|
+
setAction('unpack')
|
|
503
|
+
try {
|
|
504
|
+
const result = await unpackMiniapp(miniappId)
|
|
505
|
+
if (result.ok) {
|
|
506
|
+
toast.success(t('miniapp.toast.unpacked', { appid: detail.miniapp.appid }))
|
|
507
|
+
} else if (result.status === 'unsupported') {
|
|
508
|
+
toast.error(t('miniapp.toast.unpackUnsupported', { error: result.error }))
|
|
509
|
+
} else {
|
|
510
|
+
toast.error(t('miniapp.toast.unpackFailed', { error: result.error }))
|
|
511
|
+
}
|
|
512
|
+
onChanged()
|
|
513
|
+
setRetrySeq((value) => value + 1)
|
|
514
|
+
} catch (cause) {
|
|
515
|
+
toast.error(t('miniapp.toast.unpackFailed', { error: toMiniappErrorMessage(cause) }))
|
|
516
|
+
} finally {
|
|
517
|
+
setAction(null)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const handleOpenDir = async () => {
|
|
522
|
+
if (miniappId === null || action !== null) return
|
|
523
|
+
setAction('open')
|
|
524
|
+
try {
|
|
525
|
+
const result = await openMiniappDir(miniappId)
|
|
526
|
+
toast.success(t('miniapp.toast.opened', { path: result.path }))
|
|
527
|
+
} catch (cause) {
|
|
528
|
+
toast.error(t('miniapp.toast.openFailed', { error: toMiniappErrorMessage(cause) }))
|
|
529
|
+
} finally {
|
|
530
|
+
setAction(null)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const handleRename = async () => {
|
|
535
|
+
if (miniappId === null || detail === null || renamePending) return
|
|
536
|
+
setRenamePending(true)
|
|
537
|
+
try {
|
|
538
|
+
const result = await renameMiniapp(miniappId, nameDraft)
|
|
539
|
+
setDetail((current) =>
|
|
540
|
+
current === null ? current : { ...current, miniapp: { ...current.miniapp, ...result.miniapp } }
|
|
541
|
+
)
|
|
542
|
+
setRenaming(false)
|
|
543
|
+
onChanged()
|
|
544
|
+
toast.success(t('miniapp.detail.renamed'))
|
|
545
|
+
} catch (cause) {
|
|
546
|
+
toast.error(t('miniapp.detail.renameFailed', { error: toMiniappErrorMessage(cause) }))
|
|
547
|
+
} finally {
|
|
548
|
+
setRenamePending(false)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const analysis = detail?.analysis ?? null
|
|
553
|
+
const unpacked = detail?.miniapp.status === 'unpacked'
|
|
554
|
+
const miniappTitle =
|
|
555
|
+
(detail?.miniapp.app_name ?? '').trim() ||
|
|
556
|
+
(detail?.miniapp.kind === 'plugin'
|
|
557
|
+
? t('miniapp.kind.plugin')
|
|
558
|
+
: detail?.miniapp.kind === 'library'
|
|
559
|
+
? t('miniapp.kind.library')
|
|
560
|
+
: detail?.miniapp.kind === 'unknown'
|
|
561
|
+
? t('miniapp.kind.unknown')
|
|
562
|
+
: t('miniapp.unnamed'))
|
|
563
|
+
const overviewText = detail?.overviewText ?? ''
|
|
564
|
+
const versions = detail?.miniapp.versions ?? []
|
|
565
|
+
const versionCount = versions.length > 0 ? versions.length : (detail?.miniapp.version_count ?? 1)
|
|
566
|
+
const counts: Array<{ key: ExtractedCategory; label: string; value: number }> = [
|
|
567
|
+
{ key: 'pages', label: t('miniapp.detail.count.pages'), value: countOf(analysis, 'pages') },
|
|
568
|
+
{ key: 'subPackages', label: t('miniapp.detail.count.subPackages'), value: countOf(analysis, 'subPackages') },
|
|
569
|
+
{ key: 'domains', label: t('miniapp.detail.count.domains'), value: countOf(analysis, 'domains') },
|
|
570
|
+
{ key: 'endpoints', label: t('miniapp.detail.count.endpoints'), value: countOf(analysis, 'endpoints') },
|
|
571
|
+
{ key: 'secrets', label: t('miniapp.detail.count.secrets'), value: countOf(analysis, 'secrets') },
|
|
572
|
+
{ key: 'parameters', label: t('miniapp.detail.count.parameters'), value: countOf(analysis, 'parameters') }
|
|
573
|
+
]
|
|
574
|
+
|
|
575
|
+
const extractedRows: Array<{
|
|
576
|
+
key: string
|
|
577
|
+
label: string
|
|
578
|
+
meta: string
|
|
579
|
+
file?: string
|
|
580
|
+
find?: string
|
|
581
|
+
line?: number
|
|
582
|
+
hideFindInput?: boolean
|
|
583
|
+
}> = (() => {
|
|
584
|
+
if (!analysis) return []
|
|
585
|
+
switch (overviewCategory) {
|
|
586
|
+
case 'pages':
|
|
587
|
+
return analysis.pages.map((page) => ({ key: `page:${page}`, label: page, meta: '' }))
|
|
588
|
+
case 'subPackages':
|
|
589
|
+
return analysis.subPackages.map((entry) => ({
|
|
590
|
+
key: `sub:${entry.root}`,
|
|
591
|
+
label: entry.root,
|
|
592
|
+
meta: t('miniapp.detail.extracted.subPages', { count: entry.pages.length })
|
|
593
|
+
}))
|
|
594
|
+
case 'domains':
|
|
595
|
+
return analysis.domains.map((entry) => ({
|
|
596
|
+
key: `domain:${entry.host}`,
|
|
597
|
+
label: entry.host,
|
|
598
|
+
meta: t('miniapp.detail.extracted.hits', { count: entry.count }),
|
|
599
|
+
file: entry.files[0],
|
|
600
|
+
find: entry.host
|
|
601
|
+
}))
|
|
602
|
+
case 'endpoints':
|
|
603
|
+
return analysis.endpoints.map((entry) => ({
|
|
604
|
+
key: `endpoint:${entry.method || ''}:${entry.path}`,
|
|
605
|
+
label: `${entry.method || 'GET'} ${entry.path}`,
|
|
606
|
+
meta: t('miniapp.detail.extracted.hits', { count: entry.count }),
|
|
607
|
+
file: entry.files[0],
|
|
608
|
+
find: entry.path
|
|
609
|
+
}))
|
|
610
|
+
case 'secrets':
|
|
611
|
+
return analysis.secrets.map((entry) => {
|
|
612
|
+
const needle = secretSearchNeedle(entry.value)
|
|
613
|
+
return {
|
|
614
|
+
key: `secret:${entry.source}:${entry.line}:${entry.value_masked}`,
|
|
615
|
+
label: entry.value_masked || entry.name,
|
|
616
|
+
meta: `${entry.info_type} · ${entry.source}:${entry.line}`,
|
|
617
|
+
file: entry.source,
|
|
618
|
+
find: needle || undefined,
|
|
619
|
+
line: needle.length > 0 ? undefined : entry.line,
|
|
620
|
+
hideFindInput: needle.length > 0
|
|
621
|
+
}
|
|
622
|
+
})
|
|
623
|
+
case 'parameters':
|
|
624
|
+
return analysis.parameters.map((entry) => ({
|
|
625
|
+
key: `param:${entry.endpoint}:${entry.name}:${entry.location}`,
|
|
626
|
+
label: entry.name,
|
|
627
|
+
meta: `${entry.endpoint} · ${entry.location}${
|
|
628
|
+
entry.values.length > 0 ? ` · ${entry.values.join(', ')}` : ''
|
|
629
|
+
}`,
|
|
630
|
+
file: entry.source,
|
|
631
|
+
find: entry.endpoint
|
|
632
|
+
}))
|
|
633
|
+
default:
|
|
634
|
+
return []
|
|
635
|
+
}
|
|
636
|
+
})()
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<Sheet
|
|
640
|
+
open={miniappId !== null}
|
|
641
|
+
onOpenChange={(next) => {
|
|
642
|
+
if (!next) onClose()
|
|
643
|
+
}}
|
|
644
|
+
>
|
|
645
|
+
<SheetContent
|
|
646
|
+
data-testid="miniapp-detail"
|
|
647
|
+
className="w-[min(60rem,calc(100vw-1.75rem))]"
|
|
648
|
+
>
|
|
649
|
+
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-6">
|
|
650
|
+
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
|
651
|
+
{t('miniapp.detail.eyebrow')}
|
|
652
|
+
</span>
|
|
653
|
+
<div className="flex items-center gap-1">
|
|
654
|
+
{detail !== null && unpacked ? (
|
|
655
|
+
<Button
|
|
656
|
+
variant="ghost"
|
|
657
|
+
size="icon-sm"
|
|
658
|
+
data-testid="miniapp-detail-open-dir"
|
|
659
|
+
aria-label={t('miniapp.action.openDir')}
|
|
660
|
+
title={t('miniapp.action.openDir')}
|
|
661
|
+
disabled={action !== null}
|
|
662
|
+
onClick={() => void handleOpenDir()}
|
|
663
|
+
className="text-muted-foreground"
|
|
664
|
+
>
|
|
665
|
+
{action === 'open' ? (
|
|
666
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
667
|
+
) : (
|
|
668
|
+
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5" />
|
|
669
|
+
)}
|
|
670
|
+
</Button>
|
|
671
|
+
) : null}
|
|
672
|
+
<SheetCloseButton />
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
{loading && detail === null ? (
|
|
677
|
+
<div className="flex flex-col gap-3 p-6">
|
|
678
|
+
<Skeleton className="h-7 w-2/3" />
|
|
679
|
+
<Skeleton className="h-4 w-1/2" />
|
|
680
|
+
<Skeleton className="mt-2 h-24 w-full" />
|
|
681
|
+
<Skeleton className="h-4 w-full" />
|
|
682
|
+
<Skeleton className="h-4 w-5/6" />
|
|
683
|
+
</div>
|
|
684
|
+
) : error !== null && detail === null ? (
|
|
685
|
+
<EmptyState
|
|
686
|
+
icon={Unplug}
|
|
687
|
+
title={t('miniapp.detail.loadError')}
|
|
688
|
+
description={error}
|
|
689
|
+
className="my-auto"
|
|
690
|
+
>
|
|
691
|
+
<Button variant="outline" size="sm" onClick={() => setRetrySeq((value) => value + 1)}>
|
|
692
|
+
{t('common.retry')}
|
|
693
|
+
</Button>
|
|
694
|
+
</EmptyState>
|
|
695
|
+
) : detail !== null ? (
|
|
696
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
697
|
+
<div className="shrink-0 border-b border-border px-6 pb-4 pt-4">
|
|
698
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
699
|
+
<div className="min-w-0">
|
|
700
|
+
{renaming ? (
|
|
701
|
+
<form
|
|
702
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
703
|
+
onSubmit={(event) => {
|
|
704
|
+
event.preventDefault()
|
|
705
|
+
void handleRename()
|
|
706
|
+
}}
|
|
707
|
+
>
|
|
708
|
+
<Input
|
|
709
|
+
autoFocus
|
|
710
|
+
value={nameDraft}
|
|
711
|
+
maxLength={60}
|
|
712
|
+
onChange={(event) => setNameDraft(event.target.value)}
|
|
713
|
+
placeholder={t('miniapp.detail.renamePlaceholder')}
|
|
714
|
+
aria-label={t('miniapp.detail.rename')}
|
|
715
|
+
className="dshc-flat-input h-8 w-[260px] max-w-full text-[13px]"
|
|
716
|
+
/>
|
|
717
|
+
<Button
|
|
718
|
+
type="submit"
|
|
719
|
+
variant="outline"
|
|
720
|
+
size="icon-sm"
|
|
721
|
+
disabled={renamePending}
|
|
722
|
+
aria-label={t('miniapp.detail.renameSave')}
|
|
723
|
+
>
|
|
724
|
+
{renamePending ? (
|
|
725
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
726
|
+
) : (
|
|
727
|
+
<Check aria-hidden="true" className="h-3.5 w-3.5" />
|
|
728
|
+
)}
|
|
729
|
+
</Button>
|
|
730
|
+
<Button
|
|
731
|
+
type="button"
|
|
732
|
+
variant="ghost"
|
|
733
|
+
size="icon-sm"
|
|
734
|
+
onClick={() => setRenaming(false)}
|
|
735
|
+
aria-label={t('common.cancel')}
|
|
736
|
+
>
|
|
737
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
738
|
+
</Button>
|
|
739
|
+
</form>
|
|
740
|
+
) : (
|
|
741
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
742
|
+
<SheetTitle className="break-all text-[19px] font-medium tracking-tight text-foreground">
|
|
743
|
+
{miniappTitle}
|
|
744
|
+
</SheetTitle>
|
|
745
|
+
<Button
|
|
746
|
+
variant="ghost"
|
|
747
|
+
size="icon-sm"
|
|
748
|
+
className="shrink-0"
|
|
749
|
+
aria-label={t('miniapp.detail.rename')}
|
|
750
|
+
onClick={() => {
|
|
751
|
+
setNameDraft((detail.miniapp.app_name ?? '').trim())
|
|
752
|
+
setRenaming(true)
|
|
753
|
+
}}
|
|
754
|
+
>
|
|
755
|
+
<Pencil aria-hidden="true" className="h-3.5 w-3.5" />
|
|
756
|
+
</Button>
|
|
757
|
+
</div>
|
|
758
|
+
)}
|
|
759
|
+
<p className="mt-1 break-all font-mono text-[11.5px] text-muted-foreground">
|
|
760
|
+
{detail.miniapp.appid}
|
|
761
|
+
</p>
|
|
762
|
+
</div>
|
|
763
|
+
<CopyButton
|
|
764
|
+
value={detail.miniapp.appid}
|
|
765
|
+
label={t('common.copy')}
|
|
766
|
+
toastMessage={t('common.copied')}
|
|
767
|
+
className="mt-0.5"
|
|
768
|
+
/>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
772
|
+
<MiniappStatusBadge status={detail.miniapp.status} />
|
|
773
|
+
<MiniappFormatBadge format={detail.miniapp.format} />
|
|
774
|
+
<span className="font-mono text-[11px] tabular-nums text-foreground/70">
|
|
775
|
+
{(detail.miniapp.version ?? '').trim().length > 0
|
|
776
|
+
? t('miniapp.card.version', { version: detail.miniapp.version })
|
|
777
|
+
: t('miniapp.card.noVersion')}
|
|
778
|
+
</span>
|
|
779
|
+
{versionCount > 1 ? (
|
|
780
|
+
<span className="font-mono text-[11px] tabular-nums text-foreground/70">
|
|
781
|
+
{t('miniapp.card.versions', { count: versionCount })}
|
|
782
|
+
</span>
|
|
783
|
+
) : null}
|
|
784
|
+
{(detail.miniapp.source_count ?? 1) > 1 ? (
|
|
785
|
+
<span className="font-mono text-[11px] tabular-nums text-foreground/70">
|
|
786
|
+
{t('miniapp.card.copies', { count: detail.miniapp.source_count })}
|
|
787
|
+
</span>
|
|
788
|
+
) : null}
|
|
789
|
+
<span className="font-mono text-[11px] tabular-nums text-foreground/70">
|
|
790
|
+
{formatBytes(detail.miniapp.size_bytes)}
|
|
791
|
+
</span>
|
|
792
|
+
<span className="font-mono text-[11px] tabular-nums text-foreground/70">
|
|
793
|
+
{t('miniapp.card.updated', { time: formatDateTime(detail.miniapp.updated_at) })}
|
|
794
|
+
</span>
|
|
795
|
+
</div>
|
|
796
|
+
|
|
797
|
+
{(detail.miniapp.error ?? '').trim().length > 0 ? (
|
|
798
|
+
<p
|
|
799
|
+
role="alert"
|
|
800
|
+
className={
|
|
801
|
+
detail.miniapp.status === 'unsupported'
|
|
802
|
+
? 'mt-2 break-all rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-800'
|
|
803
|
+
: 'mt-2 break-all rounded-md border border-red-200 bg-red-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-red-700'
|
|
804
|
+
}
|
|
805
|
+
>
|
|
806
|
+
{t(
|
|
807
|
+
detail.miniapp.status === 'unsupported'
|
|
808
|
+
? 'miniapp.card.unsupportedLabel'
|
|
809
|
+
: 'miniapp.card.errorLabel',
|
|
810
|
+
{ error: detail.miniapp.error }
|
|
811
|
+
)}
|
|
812
|
+
</p>
|
|
813
|
+
) : null}
|
|
814
|
+
|
|
815
|
+
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
|
816
|
+
{!unpacked ? (
|
|
817
|
+
<Button
|
|
818
|
+
variant="outline"
|
|
819
|
+
size="xs"
|
|
820
|
+
data-testid="miniapp-detail-unpack"
|
|
821
|
+
disabled={action !== null}
|
|
822
|
+
onClick={() => void handleUnpack()}
|
|
823
|
+
className="gap-1.5"
|
|
824
|
+
>
|
|
825
|
+
{action === 'unpack' ? (
|
|
826
|
+
<Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
827
|
+
) : (
|
|
828
|
+
<Archive aria-hidden="true" className="h-3 w-3" />
|
|
829
|
+
)}
|
|
830
|
+
{action === 'unpack'
|
|
831
|
+
? t('miniapp.action.unpacking')
|
|
832
|
+
: t('miniapp.action.unpack')}
|
|
833
|
+
</Button>
|
|
834
|
+
) : (
|
|
835
|
+
<>
|
|
836
|
+
<Button
|
|
837
|
+
variant="outline"
|
|
838
|
+
size="xs"
|
|
839
|
+
data-testid="miniapp-detail-analyze"
|
|
840
|
+
disabled={action !== null}
|
|
841
|
+
onClick={() => void handleAnalyze()}
|
|
842
|
+
className="gap-1.5"
|
|
843
|
+
>
|
|
844
|
+
{action === 'analyze' ? (
|
|
845
|
+
<Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
846
|
+
) : (
|
|
847
|
+
<ScanSearch aria-hidden="true" className="h-3 w-3" />
|
|
848
|
+
)}
|
|
849
|
+
{action === 'analyze'
|
|
850
|
+
? t('miniapp.detail.generating')
|
|
851
|
+
: t('miniapp.detail.generate')}
|
|
852
|
+
</Button>
|
|
853
|
+
<Button
|
|
854
|
+
variant="outline"
|
|
855
|
+
size="xs"
|
|
856
|
+
data-testid="miniapp-detail-import"
|
|
857
|
+
disabled={action !== null || analysis === null}
|
|
858
|
+
onClick={() => void handleImport()}
|
|
859
|
+
className="gap-1.5"
|
|
860
|
+
>
|
|
861
|
+
{action === 'import' ? (
|
|
862
|
+
<Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
863
|
+
) : (
|
|
864
|
+
<Download aria-hidden="true" className="h-3 w-3" />
|
|
865
|
+
)}
|
|
866
|
+
{action === 'import'
|
|
867
|
+
? t('miniapp.detail.importing')
|
|
868
|
+
: t('miniapp.detail.importAssets')}
|
|
869
|
+
</Button>
|
|
870
|
+
</>
|
|
871
|
+
)}
|
|
872
|
+
<Button
|
|
873
|
+
variant="outline"
|
|
874
|
+
size="xs"
|
|
875
|
+
data-testid="miniapp-copy-summary"
|
|
876
|
+
disabled={overviewText.trim().length === 0 || action !== null}
|
|
877
|
+
onClick={() => void copy(overviewText, t('miniapp.detail.copiedSummary'))}
|
|
878
|
+
className="gap-1.5"
|
|
879
|
+
>
|
|
880
|
+
<Copy aria-hidden="true" className="h-3 w-3" />
|
|
881
|
+
{t('miniapp.detail.copySummary')}
|
|
882
|
+
</Button>
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
|
|
886
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 pb-6 pt-3">
|
|
887
|
+
<Tabs value={tab} onValueChange={(value) => setTab(value as DetailTab)} className="flex min-h-0 flex-1 flex-col">
|
|
888
|
+
<TabsList className="h-auto w-full flex-wrap justify-start gap-5 border-b border-border bg-transparent p-0">
|
|
889
|
+
<TabsTrigger
|
|
890
|
+
value="overview"
|
|
891
|
+
data-testid="miniapp-tab-overview"
|
|
892
|
+
className="h-8 rounded-none border-b-2 border-transparent px-0 text-[12.5px] data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
893
|
+
>
|
|
894
|
+
{t('miniapp.detail.tab.overview')}
|
|
895
|
+
</TabsTrigger>
|
|
896
|
+
<TabsTrigger
|
|
897
|
+
value="files"
|
|
898
|
+
data-testid="miniapp-tab-files"
|
|
899
|
+
disabled={!unpacked}
|
|
900
|
+
className="h-8 rounded-none border-b-2 border-transparent px-0 text-[12.5px] data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
901
|
+
>
|
|
902
|
+
{t('miniapp.detail.tab.files')}
|
|
903
|
+
</TabsTrigger>
|
|
904
|
+
<TabsTrigger
|
|
905
|
+
value="search"
|
|
906
|
+
data-testid="miniapp-tab-search"
|
|
907
|
+
disabled={!unpacked}
|
|
908
|
+
className="h-8 rounded-none border-b-2 border-transparent px-0 text-[12.5px] data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
|
909
|
+
>
|
|
910
|
+
{t('miniapp.detail.tab.search')}
|
|
911
|
+
</TabsTrigger>
|
|
912
|
+
</TabsList>
|
|
913
|
+
|
|
914
|
+
<TabsContent value="overview" className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 focus-visible:outline-none [scrollbar-gutter:stable]">
|
|
915
|
+
{analysis === null ? (
|
|
916
|
+
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-center">
|
|
917
|
+
<p className="text-[13px] font-medium text-foreground">
|
|
918
|
+
{t('miniapp.detail.overview.analysisMissing')}
|
|
919
|
+
</p>
|
|
920
|
+
<p className="mx-auto mt-1.5 max-w-[420px] text-[11.5px] leading-relaxed text-muted-foreground">
|
|
921
|
+
{t('miniapp.detail.overview.analysisMissingHint')}
|
|
922
|
+
</p>
|
|
923
|
+
<Button
|
|
924
|
+
variant="outline"
|
|
925
|
+
size="sm"
|
|
926
|
+
className="mt-4 gap-1.5"
|
|
927
|
+
disabled={!unpacked || action !== null}
|
|
928
|
+
onClick={() => void handleAnalyze()}
|
|
929
|
+
>
|
|
930
|
+
{action === 'analyze' ? (
|
|
931
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
932
|
+
) : (
|
|
933
|
+
<ScanSearch aria-hidden="true" className="h-3.5 w-3.5" />
|
|
934
|
+
)}
|
|
935
|
+
{action === 'analyze'
|
|
936
|
+
? t('miniapp.detail.generating')
|
|
937
|
+
: t('miniapp.detail.generate')}
|
|
938
|
+
</Button>
|
|
939
|
+
</div>
|
|
940
|
+
) : (
|
|
941
|
+
<>
|
|
942
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
943
|
+
{counts.map((entry) => (
|
|
944
|
+
<button
|
|
945
|
+
key={entry.key}
|
|
946
|
+
type="button"
|
|
947
|
+
data-testid={`miniapp-count-${entry.key}`}
|
|
948
|
+
aria-pressed={overviewCategory === entry.key}
|
|
949
|
+
onClick={() => setOverviewCategory(entry.key)}
|
|
950
|
+
className={cn(
|
|
951
|
+
'rounded-lg border bg-background px-3 py-2 text-left transition-colors hover:bg-sidebar focus-visible:bg-sidebar focus-visible:outline-none',
|
|
952
|
+
overviewCategory === entry.key
|
|
953
|
+
? 'border-accent'
|
|
954
|
+
: 'border-border'
|
|
955
|
+
)}
|
|
956
|
+
>
|
|
957
|
+
<p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
|
958
|
+
{entry.label}
|
|
959
|
+
</p>
|
|
960
|
+
<p className="mt-0.5 font-mono text-[17px] tabular-nums text-foreground">
|
|
961
|
+
{entry.value}
|
|
962
|
+
</p>
|
|
963
|
+
</button>
|
|
964
|
+
))}
|
|
965
|
+
</div>
|
|
966
|
+
|
|
967
|
+
<section className="mt-4" data-testid="miniapp-extracted">
|
|
968
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
969
|
+
<h3 className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
|
970
|
+
{t('miniapp.detail.extracted.heading')}
|
|
971
|
+
</h3>
|
|
972
|
+
<span className="text-[10.5px] text-muted-foreground">
|
|
973
|
+
{t('miniapp.detail.extracted.hint')}
|
|
974
|
+
</span>
|
|
975
|
+
</div>
|
|
976
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
977
|
+
{counts.map((entry) => (
|
|
978
|
+
<button
|
|
979
|
+
key={`tab-${entry.key}`}
|
|
980
|
+
type="button"
|
|
981
|
+
data-testid={`miniapp-extracted-tab-${entry.key}`}
|
|
982
|
+
aria-pressed={overviewCategory === entry.key}
|
|
983
|
+
onClick={() => setOverviewCategory(entry.key)}
|
|
984
|
+
className={cn(
|
|
985
|
+
'inline-flex h-6 items-center gap-1 rounded border px-2 text-[10.5px] transition-colors',
|
|
986
|
+
overviewCategory === entry.key
|
|
987
|
+
? 'border-accent bg-accent-soft font-medium text-accent'
|
|
988
|
+
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
|
989
|
+
)}
|
|
990
|
+
>
|
|
991
|
+
{entry.label}
|
|
992
|
+
<span className="font-mono tabular-nums">{entry.value}</span>
|
|
993
|
+
</button>
|
|
994
|
+
))}
|
|
995
|
+
</div>
|
|
996
|
+
<div className="mt-2 max-h-[260px] overflow-y-auto rounded-md border border-border bg-background [scrollbar-gutter:stable]">
|
|
997
|
+
{extractedRows.length === 0 ? (
|
|
998
|
+
<p className="px-3 py-4 text-[11px] text-muted-foreground">
|
|
999
|
+
{t('miniapp.detail.extracted.empty')}
|
|
1000
|
+
</p>
|
|
1001
|
+
) : (
|
|
1002
|
+
<ul>
|
|
1003
|
+
{extractedRows.map((row) => (
|
|
1004
|
+
<li
|
|
1005
|
+
key={row.key}
|
|
1006
|
+
className="flex items-center gap-2 border-b border-border px-3 py-1.5 last:border-b-0"
|
|
1007
|
+
>
|
|
1008
|
+
<div className="min-w-0 flex-1">
|
|
1009
|
+
<p
|
|
1010
|
+
className="truncate font-mono text-[11px] text-foreground"
|
|
1011
|
+
title={row.label}
|
|
1012
|
+
>
|
|
1013
|
+
{row.label}
|
|
1014
|
+
</p>
|
|
1015
|
+
{row.meta.length > 0 ? (
|
|
1016
|
+
<p className="truncate text-[10px] text-muted-foreground">
|
|
1017
|
+
{row.meta}
|
|
1018
|
+
</p>
|
|
1019
|
+
) : null}
|
|
1020
|
+
</div>
|
|
1021
|
+
{row.file ? (
|
|
1022
|
+
<Button
|
|
1023
|
+
variant="ghost"
|
|
1024
|
+
size="xs"
|
|
1025
|
+
className="shrink-0"
|
|
1026
|
+
onClick={() =>
|
|
1027
|
+
openAnalysisFile(row.file!, {
|
|
1028
|
+
find: row.find,
|
|
1029
|
+
line: row.line,
|
|
1030
|
+
hideFindInput: row.hideFindInput
|
|
1031
|
+
})
|
|
1032
|
+
}
|
|
1033
|
+
>
|
|
1034
|
+
{t('miniapp.detail.extracted.open')}
|
|
1035
|
+
</Button>
|
|
1036
|
+
) : null}
|
|
1037
|
+
</li>
|
|
1038
|
+
))}
|
|
1039
|
+
</ul>
|
|
1040
|
+
)}
|
|
1041
|
+
</div>
|
|
1042
|
+
</section>
|
|
1043
|
+
|
|
1044
|
+
<p className="mt-3 font-mono text-[11px] text-muted-foreground">
|
|
1045
|
+
{t('miniapp.detail.overview.filesSummary', {
|
|
1046
|
+
text: analysis.files.text,
|
|
1047
|
+
binary: analysis.files.binary,
|
|
1048
|
+
size: formatBytes(analysis.files.bytes)
|
|
1049
|
+
})}
|
|
1050
|
+
</p>
|
|
1051
|
+
</>
|
|
1052
|
+
)}
|
|
1053
|
+
|
|
1054
|
+
{importResult !== null ? (
|
|
1055
|
+
<section
|
|
1056
|
+
data-testid="miniapp-import-result"
|
|
1057
|
+
className={cn(
|
|
1058
|
+
'mt-5 rounded-lg border px-3 py-2.5',
|
|
1059
|
+
importResult.ok
|
|
1060
|
+
? 'border-emerald-200 bg-emerald-50'
|
|
1061
|
+
: 'border-amber-200 bg-amber-50'
|
|
1062
|
+
)}
|
|
1063
|
+
>
|
|
1064
|
+
<p
|
|
1065
|
+
className={cn(
|
|
1066
|
+
'text-[11.5px] font-medium',
|
|
1067
|
+
importResult.ok ? 'text-emerald-800' : 'text-amber-800'
|
|
1068
|
+
)}
|
|
1069
|
+
>
|
|
1070
|
+
{t('miniapp.detail.importResult.title')}
|
|
1071
|
+
</p>
|
|
1072
|
+
<p
|
|
1073
|
+
className={cn(
|
|
1074
|
+
'mt-1 text-[11px]',
|
|
1075
|
+
importResult.ok ? 'text-emerald-900/80' : 'text-amber-900/80'
|
|
1076
|
+
)}
|
|
1077
|
+
>
|
|
1078
|
+
{t('miniapp.detail.importResult.project', { project: importResult.project })}
|
|
1079
|
+
</p>
|
|
1080
|
+
<p
|
|
1081
|
+
className={cn(
|
|
1082
|
+
'mt-0.5 font-mono text-[11px] tabular-nums',
|
|
1083
|
+
importResult.ok ? 'text-emerald-900/80' : 'text-amber-900/80'
|
|
1084
|
+
)}
|
|
1085
|
+
>
|
|
1086
|
+
{t('miniapp.detail.importResult.totals', { ...importResult.totals })}
|
|
1087
|
+
</p>
|
|
1088
|
+
{importResult.warnings.length > 0 ? (
|
|
1089
|
+
<p className="mt-1 break-all text-[11px] text-amber-800">
|
|
1090
|
+
{t('miniapp.detail.importResult.warnings', {
|
|
1091
|
+
warnings: importResult.warnings.join('; ')
|
|
1092
|
+
})}
|
|
1093
|
+
</p>
|
|
1094
|
+
) : null}
|
|
1095
|
+
</section>
|
|
1096
|
+
) : null}
|
|
1097
|
+
|
|
1098
|
+
{versions.length > 1 ? (
|
|
1099
|
+
<section className="mt-5">
|
|
1100
|
+
<h3 className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
|
1101
|
+
{t('miniapp.detail.overview.versions')}
|
|
1102
|
+
</h3>
|
|
1103
|
+
<ul className="mt-2 overflow-hidden rounded-md border border-border">
|
|
1104
|
+
{versions.map((version) => (
|
|
1105
|
+
<li
|
|
1106
|
+
key={version.id}
|
|
1107
|
+
className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-[11px] last:border-b-0"
|
|
1108
|
+
>
|
|
1109
|
+
<span className="font-mono tabular-nums text-foreground">
|
|
1110
|
+
{(version.version ?? '').trim().length > 0
|
|
1111
|
+
? t('miniapp.card.version', { version: version.version })
|
|
1112
|
+
: t('miniapp.card.noVersion')}
|
|
1113
|
+
</span>
|
|
1114
|
+
<MiniappStatusBadge status={version.status} />
|
|
1115
|
+
<MiniappFormatBadge format={version.format} />
|
|
1116
|
+
<span className="ml-auto font-mono text-[10.5px] tabular-nums text-muted-foreground">
|
|
1117
|
+
{t('miniapp.detail.versionMeta', {
|
|
1118
|
+
status: t(`miniapp.status.${version.status}`),
|
|
1119
|
+
format: version.format,
|
|
1120
|
+
size: formatBytes(version.size_bytes),
|
|
1121
|
+
files: version.file_count,
|
|
1122
|
+
time: formatDateTime(version.updated_at)
|
|
1123
|
+
})}
|
|
1124
|
+
</span>
|
|
1125
|
+
</li>
|
|
1126
|
+
))}
|
|
1127
|
+
</ul>
|
|
1128
|
+
</section>
|
|
1129
|
+
) : null}
|
|
1130
|
+
|
|
1131
|
+
<section className="mt-5">
|
|
1132
|
+
<h3 className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
|
1133
|
+
{t('miniapp.detail.overview.heading')}
|
|
1134
|
+
</h3>
|
|
1135
|
+
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1.5 text-[11.5px] sm:grid-cols-[auto,1fr]">
|
|
1136
|
+
<dt className="text-muted-foreground">{t('miniapp.detail.meta.rootKind')}</dt>
|
|
1137
|
+
<dd className="font-mono text-foreground">{detail.miniapp.root_kind}</dd>
|
|
1138
|
+
<dt className="text-muted-foreground">{t('miniapp.detail.meta.packageDir')}</dt>
|
|
1139
|
+
<dd className="break-all font-mono text-foreground">{detail.miniapp.package_dir}</dd>
|
|
1140
|
+
{(detail.miniapp.unpacked_path ?? '').trim().length > 0 ? (
|
|
1141
|
+
<>
|
|
1142
|
+
<dt className="text-muted-foreground">{t('miniapp.detail.meta.unpackedPath')}</dt>
|
|
1143
|
+
<dd className="break-all font-mono text-foreground">
|
|
1144
|
+
{detail.miniapp.unpacked_path}
|
|
1145
|
+
</dd>
|
|
1146
|
+
</>
|
|
1147
|
+
) : null}
|
|
1148
|
+
<dt className="text-muted-foreground">{t('miniapp.detail.meta.format')}</dt>
|
|
1149
|
+
<dd className="font-mono text-foreground">{detail.miniapp.format}</dd>
|
|
1150
|
+
<dt className="text-muted-foreground">{t('miniapp.detail.meta.updated')}</dt>
|
|
1151
|
+
<dd className="font-mono text-foreground">
|
|
1152
|
+
{formatDateTimeFull(detail.miniapp.updated_at)}
|
|
1153
|
+
</dd>
|
|
1154
|
+
</dl>
|
|
1155
|
+
</section>
|
|
1156
|
+
</TabsContent>
|
|
1157
|
+
|
|
1158
|
+
<TabsContent value="files" className="mt-4 min-h-0 flex-1 focus-visible:outline-none">
|
|
1159
|
+
<div className="flex h-full min-h-0 flex-1 gap-3">
|
|
1160
|
+
{detail.miniapp.id ? (
|
|
1161
|
+
<div className="flex w-[260px] shrink-0 flex-col">
|
|
1162
|
+
<FileTree
|
|
1163
|
+
key={detail.miniapp.id}
|
|
1164
|
+
miniappId={detail.miniapp.id}
|
|
1165
|
+
onOpenFile={(path) =>
|
|
1166
|
+
void loadFile(path, 0, false, { find: '', findRegex: false })
|
|
1167
|
+
}
|
|
1168
|
+
/>
|
|
1169
|
+
</div>
|
|
1170
|
+
) : null}
|
|
1171
|
+
|
|
1172
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-background">
|
|
1173
|
+
<div className="flex h-8 shrink-0 items-center gap-2 border-b border-border px-3">
|
|
1174
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[10.5px] text-muted-foreground">
|
|
1175
|
+
{previewPath ?? t('miniapp.file.previewTitle')}
|
|
1176
|
+
</span>
|
|
1177
|
+
{preview !== null ? (
|
|
1178
|
+
<>
|
|
1179
|
+
{preview.formatted ? (
|
|
1180
|
+
<span className="shrink-0 rounded border border-border bg-secondary px-1.5 py-0.5 text-[9.5px] font-medium text-muted-foreground">
|
|
1181
|
+
{t('miniapp.file.formatted')}
|
|
1182
|
+
</span>
|
|
1183
|
+
) : null}
|
|
1184
|
+
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
|
1185
|
+
{t('miniapp.file.totalLines', { count: preview.totalLines })}
|
|
1186
|
+
</span>
|
|
1187
|
+
<CopyButton
|
|
1188
|
+
value={preview.content}
|
|
1189
|
+
label={t('common.copy')}
|
|
1190
|
+
toastMessage={t('common.copied')}
|
|
1191
|
+
/>
|
|
1192
|
+
</>
|
|
1193
|
+
) : null}
|
|
1194
|
+
</div>
|
|
1195
|
+
{preview !== null && !preview.binary ? (
|
|
1196
|
+
<form
|
|
1197
|
+
onSubmit={handleFileFind}
|
|
1198
|
+
className="flex h-8 shrink-0 items-center gap-1.5 border-b border-border px-3"
|
|
1199
|
+
>
|
|
1200
|
+
<Search
|
|
1201
|
+
aria-hidden="true"
|
|
1202
|
+
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
|
1203
|
+
/>
|
|
1204
|
+
<Input
|
|
1205
|
+
data-testid="miniapp-file-find"
|
|
1206
|
+
aria-label={t('miniapp.file.findPlaceholder')}
|
|
1207
|
+
value={fileFindInput}
|
|
1208
|
+
onChange={(event) => setFileFindInput(event.target.value)}
|
|
1209
|
+
placeholder={t('miniapp.file.findPlaceholder')}
|
|
1210
|
+
className="dshc-flat-input h-6 min-w-0 flex-1 border-0 bg-transparent px-0 text-[11px] shadow-none focus-visible:ring-0"
|
|
1211
|
+
/>
|
|
1212
|
+
<button
|
|
1213
|
+
type="button"
|
|
1214
|
+
role="switch"
|
|
1215
|
+
aria-checked={fileFindRegex}
|
|
1216
|
+
aria-label={t('miniapp.file.findRegex')}
|
|
1217
|
+
data-testid="miniapp-file-find-regex"
|
|
1218
|
+
onClick={() => setFileFindRegex((value) => !value)}
|
|
1219
|
+
className={cn(
|
|
1220
|
+
'inline-flex h-6 items-center rounded border px-1.5 font-mono text-[10px] transition-colors',
|
|
1221
|
+
fileFindRegex
|
|
1222
|
+
? 'border-accent bg-accent-soft font-medium text-accent'
|
|
1223
|
+
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
|
1224
|
+
)}
|
|
1225
|
+
>
|
|
1226
|
+
.*
|
|
1227
|
+
</button>
|
|
1228
|
+
<Button
|
|
1229
|
+
type="submit"
|
|
1230
|
+
variant="outline"
|
|
1231
|
+
size="xs"
|
|
1232
|
+
data-testid="miniapp-file-find-submit"
|
|
1233
|
+
disabled={fileFindInput.trim().length === 0}
|
|
1234
|
+
>
|
|
1235
|
+
{t('miniapp.file.findSubmit')}
|
|
1236
|
+
</Button>
|
|
1237
|
+
{fileFind.length > 0 ? (
|
|
1238
|
+
(preview.matchCount ?? 0) > 0 ? (
|
|
1239
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
1240
|
+
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">
|
|
1241
|
+
{Math.max(0, activeMatchIndex + 1)} / {preview.matchCount}
|
|
1242
|
+
{preview.matchTruncated ? '+' : ''}
|
|
1243
|
+
</span>
|
|
1244
|
+
<Button
|
|
1245
|
+
type="button"
|
|
1246
|
+
variant="ghost"
|
|
1247
|
+
size="icon-sm"
|
|
1248
|
+
aria-label={t('miniapp.file.findPrev')}
|
|
1249
|
+
onClick={() => jumpToMatch(-1)}
|
|
1250
|
+
>
|
|
1251
|
+
<ChevronUp aria-hidden="true" className="h-3.5 w-3.5" />
|
|
1252
|
+
</Button>
|
|
1253
|
+
<Button
|
|
1254
|
+
type="button"
|
|
1255
|
+
variant="ghost"
|
|
1256
|
+
size="icon-sm"
|
|
1257
|
+
aria-label={t('miniapp.file.findNext')}
|
|
1258
|
+
onClick={() => jumpToMatch(1)}
|
|
1259
|
+
>
|
|
1260
|
+
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
|
|
1261
|
+
</Button>
|
|
1262
|
+
</div>
|
|
1263
|
+
) : (
|
|
1264
|
+
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
|
1265
|
+
{t('miniapp.file.findNone')}
|
|
1266
|
+
</span>
|
|
1267
|
+
)
|
|
1268
|
+
) : null}
|
|
1269
|
+
</form>
|
|
1270
|
+
) : null}
|
|
1271
|
+
<div
|
|
1272
|
+
ref={previewScrollRef}
|
|
1273
|
+
className="min-h-0 flex-1 overflow-auto"
|
|
1274
|
+
onScroll={handlePreviewScroll}
|
|
1275
|
+
data-testid="miniapp-file-scroll"
|
|
1276
|
+
>
|
|
1277
|
+
{previewLoading ? (
|
|
1278
|
+
<div className="flex flex-col gap-2 p-3">
|
|
1279
|
+
<Skeleton className="h-3 w-2/3" />
|
|
1280
|
+
<Skeleton className="h-3 w-full" />
|
|
1281
|
+
<Skeleton className="h-3 w-5/6" />
|
|
1282
|
+
</div>
|
|
1283
|
+
) : previewError !== null ? (
|
|
1284
|
+
<p className="p-3 text-[11.5px] leading-relaxed text-red-700">{previewError}</p>
|
|
1285
|
+
) : preview !== null ? (
|
|
1286
|
+
previewHtml !== null ? (
|
|
1287
|
+
<pre
|
|
1288
|
+
data-testid="miniapp-file-preview"
|
|
1289
|
+
className={cn(
|
|
1290
|
+
'whitespace-pre px-3 py-2 font-mono text-[11px] leading-[1.65] text-foreground',
|
|
1291
|
+
preview.binary && 'text-muted-foreground'
|
|
1292
|
+
)}
|
|
1293
|
+
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
|
1294
|
+
/>
|
|
1295
|
+
) : (
|
|
1296
|
+
<pre
|
|
1297
|
+
data-testid="miniapp-file-preview"
|
|
1298
|
+
className={cn(
|
|
1299
|
+
'whitespace-pre px-3 py-2 font-mono text-[11px] leading-[1.65] text-foreground',
|
|
1300
|
+
preview.binary && 'text-muted-foreground'
|
|
1301
|
+
)}
|
|
1302
|
+
>
|
|
1303
|
+
{preview.content}
|
|
1304
|
+
</pre>
|
|
1305
|
+
)
|
|
1306
|
+
) : (
|
|
1307
|
+
<p className="p-3 text-[11.5px] text-muted-foreground">
|
|
1308
|
+
{t('miniapp.file.selectPrompt')}
|
|
1309
|
+
</p>
|
|
1310
|
+
)}
|
|
1311
|
+
</div>
|
|
1312
|
+
{preview !== null && !preview.binary ? (
|
|
1313
|
+
<div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-2">
|
|
1314
|
+
<span className="text-[10.5px] text-muted-foreground">
|
|
1315
|
+
{preview.sourceTruncated
|
|
1316
|
+
? t('miniapp.file.sourceTruncated')
|
|
1317
|
+
: preview.hasMore
|
|
1318
|
+
? t('miniapp.file.loadedProgress', {
|
|
1319
|
+
loaded:
|
|
1320
|
+
(preview.offset ?? 0) +
|
|
1321
|
+
(preview.loadedLines ?? preview.content.split('\n').length),
|
|
1322
|
+
total: preview.totalLines
|
|
1323
|
+
})
|
|
1324
|
+
: t('miniapp.file.allLoaded', { total: preview.totalLines })}
|
|
1325
|
+
</span>
|
|
1326
|
+
{preview.hasMore ? (
|
|
1327
|
+
<Button
|
|
1328
|
+
variant="ghost"
|
|
1329
|
+
size="xs"
|
|
1330
|
+
className="ml-auto gap-1"
|
|
1331
|
+
disabled={previewLoadingMore}
|
|
1332
|
+
onClick={() =>
|
|
1333
|
+
void loadFile(
|
|
1334
|
+
preview.path,
|
|
1335
|
+
(preview.offset ?? 0) + (preview.loadedLines ?? 0),
|
|
1336
|
+
true
|
|
1337
|
+
)
|
|
1338
|
+
}
|
|
1339
|
+
>
|
|
1340
|
+
{previewLoadingMore ? (
|
|
1341
|
+
<Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
1342
|
+
) : null}
|
|
1343
|
+
{t('miniapp.file.loadMore')}
|
|
1344
|
+
</Button>
|
|
1345
|
+
) : null}
|
|
1346
|
+
</div>
|
|
1347
|
+
) : null}
|
|
1348
|
+
</div>
|
|
1349
|
+
</div>
|
|
1350
|
+
</TabsContent>
|
|
1351
|
+
|
|
1352
|
+
<TabsContent value="search" className="mt-4 flex min-h-0 flex-1 flex-col focus-visible:outline-none">
|
|
1353
|
+
<form onSubmit={(event) => void runSearch(event)} className="flex flex-wrap items-end gap-2">
|
|
1354
|
+
<div className="relative min-w-[200px] flex-1">
|
|
1355
|
+
<Search
|
|
1356
|
+
aria-hidden="true"
|
|
1357
|
+
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
|
1358
|
+
/>
|
|
1359
|
+
<Input
|
|
1360
|
+
data-testid="miniapp-search-query"
|
|
1361
|
+
aria-label={t('miniapp.search.queryAria')}
|
|
1362
|
+
value={searchQuery}
|
|
1363
|
+
onChange={(event) => setSearchQuery(event.target.value)}
|
|
1364
|
+
placeholder={t('miniapp.search.queryPlaceholder')}
|
|
1365
|
+
className="dshc-flat-input h-8 pl-8 text-[11.5px]"
|
|
1366
|
+
/>
|
|
1367
|
+
</div>
|
|
1368
|
+
<button
|
|
1369
|
+
type="button"
|
|
1370
|
+
role="switch"
|
|
1371
|
+
aria-checked={searchRegex}
|
|
1372
|
+
data-testid="miniapp-search-regex"
|
|
1373
|
+
onClick={() => setSearchRegex((value) => !value)}
|
|
1374
|
+
className={cn(
|
|
1375
|
+
'inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-[11.5px] transition-colors',
|
|
1376
|
+
searchRegex
|
|
1377
|
+
? 'border-accent bg-accent-soft font-medium text-accent'
|
|
1378
|
+
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
|
1379
|
+
)}
|
|
1380
|
+
>
|
|
1381
|
+
{t('miniapp.search.regex')}
|
|
1382
|
+
</button>
|
|
1383
|
+
<Input
|
|
1384
|
+
data-testid="miniapp-search-glob"
|
|
1385
|
+
aria-label={t('miniapp.search.glob')}
|
|
1386
|
+
value={searchGlob}
|
|
1387
|
+
onChange={(event) => setSearchGlob(event.target.value)}
|
|
1388
|
+
placeholder={t('miniapp.search.globPlaceholder')}
|
|
1389
|
+
className="dshc-flat-input h-8 w-[160px] font-mono text-[11px]"
|
|
1390
|
+
/>
|
|
1391
|
+
<label className="flex items-center gap-1.5 text-[10.5px] text-muted-foreground">
|
|
1392
|
+
{t('miniapp.search.maxResults')}
|
|
1393
|
+
<Input
|
|
1394
|
+
data-testid="miniapp-search-max"
|
|
1395
|
+
type="number"
|
|
1396
|
+
min={1}
|
|
1397
|
+
max={200}
|
|
1398
|
+
value={searchMax}
|
|
1399
|
+
onChange={(event) => setSearchMax(event.target.value)}
|
|
1400
|
+
className="dshc-flat-input h-8 w-[68px] text-[11px]"
|
|
1401
|
+
/>
|
|
1402
|
+
</label>
|
|
1403
|
+
<Button
|
|
1404
|
+
type="submit"
|
|
1405
|
+
variant="outline"
|
|
1406
|
+
size="sm"
|
|
1407
|
+
data-testid="miniapp-search-submit"
|
|
1408
|
+
disabled={searching || searchQuery.trim().length === 0}
|
|
1409
|
+
className="gap-1.5"
|
|
1410
|
+
>
|
|
1411
|
+
{searching ? (
|
|
1412
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
1413
|
+
) : (
|
|
1414
|
+
<Search aria-hidden="true" className="h-3.5 w-3.5" />
|
|
1415
|
+
)}
|
|
1416
|
+
{searching ? t('miniapp.search.searching') : t('miniapp.search.submit')}
|
|
1417
|
+
</Button>
|
|
1418
|
+
</form>
|
|
1419
|
+
|
|
1420
|
+
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
1421
|
+
{searchError !== null ? (
|
|
1422
|
+
<p role="alert" className="mt-3 text-[11.5px] text-red-700">
|
|
1423
|
+
{searchError}
|
|
1424
|
+
</p>
|
|
1425
|
+
) : searchResults === null ? (
|
|
1426
|
+
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
|
1427
|
+
{t('miniapp.search.empty')}
|
|
1428
|
+
</p>
|
|
1429
|
+
) : (
|
|
1430
|
+
<>
|
|
1431
|
+
<p className="mt-3 font-mono text-[10.5px] text-muted-foreground">
|
|
1432
|
+
{t('miniapp.search.summary', {
|
|
1433
|
+
count: searchResults.matches.length,
|
|
1434
|
+
scanned: searchResults.scanned
|
|
1435
|
+
})}
|
|
1436
|
+
{searchResults.truncated ? ` ${t('miniapp.search.truncated')}` : ''}
|
|
1437
|
+
</p>
|
|
1438
|
+
{searchResults.matches.length === 0 ? (
|
|
1439
|
+
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
|
1440
|
+
{t('miniapp.search.noResults')}
|
|
1441
|
+
</p>
|
|
1442
|
+
) : (
|
|
1443
|
+
<ul className="mt-2 overflow-hidden rounded-md border border-border">
|
|
1444
|
+
{searchResults.matches.map((match, index) => (
|
|
1445
|
+
<li key={`${match.file}:${match.line}:${index}`} className="border-b border-border last:border-b-0">
|
|
1446
|
+
<button
|
|
1447
|
+
type="button"
|
|
1448
|
+
data-testid="miniapp-search-result"
|
|
1449
|
+
aria-label={t('miniapp.search.resultAria', {
|
|
1450
|
+
file: match.file,
|
|
1451
|
+
line: match.line
|
|
1452
|
+
})}
|
|
1453
|
+
onClick={() => {
|
|
1454
|
+
setTab('files')
|
|
1455
|
+
const query = searchQuery.trim()
|
|
1456
|
+
setFileFindInput(query)
|
|
1457
|
+
void loadFile(
|
|
1458
|
+
match.file,
|
|
1459
|
+
Math.floor((match.line - 1) / PREVIEW_LINES) * PREVIEW_LINES,
|
|
1460
|
+
false,
|
|
1461
|
+
{
|
|
1462
|
+
find: query,
|
|
1463
|
+
findRegex: searchRegex,
|
|
1464
|
+
scrollLine: match.line
|
|
1465
|
+
}
|
|
1466
|
+
)
|
|
1467
|
+
}}
|
|
1468
|
+
className="flex w-full items-baseline gap-3 px-3 py-1.5 text-left transition-colors hover:bg-sidebar focus-visible:bg-accent-soft focus-visible:outline-none"
|
|
1469
|
+
>
|
|
1470
|
+
<span className="shrink-0 font-mono text-[10.5px] text-muted-foreground">
|
|
1471
|
+
{match.file}:{match.line}
|
|
1472
|
+
</span>
|
|
1473
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground">
|
|
1474
|
+
{match.text}
|
|
1475
|
+
</span>
|
|
1476
|
+
</button>
|
|
1477
|
+
</li>
|
|
1478
|
+
))}
|
|
1479
|
+
</ul>
|
|
1480
|
+
)}
|
|
1481
|
+
</>
|
|
1482
|
+
)}
|
|
1483
|
+
</div>
|
|
1484
|
+
</TabsContent>
|
|
1485
|
+
</Tabs>
|
|
1486
|
+
</div>
|
|
1487
|
+
</div>
|
|
1488
|
+
) : null}
|
|
1489
|
+
</SheetContent>
|
|
1490
|
+
</Sheet>
|
|
1491
|
+
)
|
|
1492
|
+
}
|
|
1493
|
+
|