@lievclouds/dsh-cortex 0.4.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.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -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, '&lt;')
61
+ .replace(/>/g, '&gt;')
62
+ .replace(/"/g, '&quot;')
63
+ .replace(/'/g, '&#39;')
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
+