@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,664 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { FormEvent } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Boxes,
|
|
5
|
+
FolderPlus,
|
|
6
|
+
Loader2,
|
|
7
|
+
PackageSearch,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
Search,
|
|
10
|
+
ShieldCheck,
|
|
11
|
+
X
|
|
12
|
+
} from 'lucide-react'
|
|
13
|
+
import { toast } from 'sonner'
|
|
14
|
+
import { Button } from '@renderer/components/ui/button'
|
|
15
|
+
import { Input } from '@renderer/components/ui/input'
|
|
16
|
+
import { Skeleton } from '@renderer/components/ui/skeleton'
|
|
17
|
+
import {
|
|
18
|
+
Select,
|
|
19
|
+
SelectContent,
|
|
20
|
+
SelectItem,
|
|
21
|
+
SelectTrigger,
|
|
22
|
+
SelectValue
|
|
23
|
+
} from '@renderer/components/ui/select'
|
|
24
|
+
import { EmptyState } from '@renderer/components/common/EmptyState'
|
|
25
|
+
import { MiniappCard } from '@renderer/components/miniapp/MiniappCard'
|
|
26
|
+
import type { MiniappCardAction } from '@renderer/components/miniapp/MiniappCard'
|
|
27
|
+
import { MiniappDetail } from '@renderer/components/miniapp/MiniappDetail'
|
|
28
|
+
import {
|
|
29
|
+
MINIAPP_STATUSES,
|
|
30
|
+
analyzeMiniapp,
|
|
31
|
+
importMiniappAssets,
|
|
32
|
+
listMiniappRoots,
|
|
33
|
+
listMiniapps,
|
|
34
|
+
openMiniappDir,
|
|
35
|
+
scanMiniapps,
|
|
36
|
+
setMiniappRoots,
|
|
37
|
+
toMiniappErrorMessage,
|
|
38
|
+
unpackMiniapp,
|
|
39
|
+
type MiniappListResponse,
|
|
40
|
+
type MiniappRootsResponse,
|
|
41
|
+
type MiniappRow,
|
|
42
|
+
type MiniappScanRoot,
|
|
43
|
+
type MiniappStatus
|
|
44
|
+
} from '@renderer/lib/miniappApi'
|
|
45
|
+
import { useDebouncedValue } from '@renderer/hooks/useDebouncedValue'
|
|
46
|
+
import { useI18n } from '@renderer/i18n'
|
|
47
|
+
import { cn } from '@renderer/lib/utils'
|
|
48
|
+
|
|
49
|
+
interface MiniappPageProps {
|
|
50
|
+
dataVersion: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const EMPTY_LIST: MiniappListResponse = {
|
|
54
|
+
miniapps: [],
|
|
55
|
+
total: 0,
|
|
56
|
+
appCount: 0,
|
|
57
|
+
customRoots: [],
|
|
58
|
+
workspaceRoot: '',
|
|
59
|
+
lastScanAt: null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Mini-program (wxapkg) reverse-engineering workbench.
|
|
64
|
+
*
|
|
65
|
+
* The page only reads the host routes under `/cortex-api/cortex/miniapps`; the
|
|
66
|
+
* host owns the WeChat cache read-only contract. Every destructive-looking
|
|
67
|
+
* action here (unpack/analyze/import/open) is explicit and disabled while a
|
|
68
|
+
* request is in flight, and the detail inspector opens on browse.
|
|
69
|
+
*/
|
|
70
|
+
export function MiniappPage({ dataVersion }: MiniappPageProps) {
|
|
71
|
+
const { t } = useI18n()
|
|
72
|
+
const [data, setData] = useState<MiniappListResponse | null>(null)
|
|
73
|
+
const [loading, setLoading] = useState(true)
|
|
74
|
+
const [error, setError] = useState<string | null>(null)
|
|
75
|
+
const [search, setSearch] = useState('')
|
|
76
|
+
const debouncedSearch = useDebouncedValue(search.trim(), 180)
|
|
77
|
+
const [status, setStatus] = useState<MiniappStatus | 'all'>('all')
|
|
78
|
+
const [showExtras, setShowExtras] = useState(false)
|
|
79
|
+
const [retrySeq, setRetrySeq] = useState(0)
|
|
80
|
+
const [refreshSeq, setRefreshSeq] = useState(0)
|
|
81
|
+
const [scanPending, setScanPending] = useState(false)
|
|
82
|
+
const [scanError, setScanError] = useState<string | null>(null)
|
|
83
|
+
const [roots, setRoots] = useState<MiniappRootsResponse | null>(null)
|
|
84
|
+
const [scanRoots, setScanRoots] = useState<MiniappScanRoot[] | null>(null)
|
|
85
|
+
const [addRootOpen, setAddRootOpen] = useState(false)
|
|
86
|
+
const [addRootValue, setAddRootValue] = useState('')
|
|
87
|
+
const [addRootPending, setAddRootPending] = useState(false)
|
|
88
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
89
|
+
const [busy, setBusy] = useState<{ id: string; action: MiniappCardAction } | null>(null)
|
|
90
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
91
|
+
|
|
92
|
+
const filtersActive = search.trim().length > 0 || status !== 'all'
|
|
93
|
+
|
|
94
|
+
const load = useCallback(
|
|
95
|
+
async (silent: boolean) => {
|
|
96
|
+
if (!silent) setLoading(true)
|
|
97
|
+
try {
|
|
98
|
+
const next = await listMiniapps({
|
|
99
|
+
search: debouncedSearch,
|
|
100
|
+
status: status === 'all' ? '' : status
|
|
101
|
+
})
|
|
102
|
+
setData(next)
|
|
103
|
+
setError(null)
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
setError(toMiniappErrorMessage(cause))
|
|
106
|
+
} finally {
|
|
107
|
+
setLoading(false)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[debouncedSearch, status]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
void load(data !== null)
|
|
115
|
+
}, [load, dataVersion, retrySeq, refreshSeq])
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
let alive = true
|
|
119
|
+
listMiniappRoots().then(
|
|
120
|
+
(next) => {
|
|
121
|
+
if (alive) setRoots(next)
|
|
122
|
+
},
|
|
123
|
+
() => {
|
|
124
|
+
/* root metadata is informational; the scan still works without it */
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return () => {
|
|
128
|
+
alive = false
|
|
129
|
+
}
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
134
|
+
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return
|
|
135
|
+
const target = event.target as HTMLElement | null
|
|
136
|
+
if (
|
|
137
|
+
target &&
|
|
138
|
+
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
139
|
+
) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
event.preventDefault()
|
|
143
|
+
searchInputRef.current?.focus()
|
|
144
|
+
}
|
|
145
|
+
window.addEventListener('keydown', onKeyDown)
|
|
146
|
+
return () => window.removeEventListener('keydown', onKeyDown)
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
const replaceRow = (row: MiniappRow): void => {
|
|
150
|
+
setData((prev) => {
|
|
151
|
+
if (prev === null) return prev
|
|
152
|
+
return {
|
|
153
|
+
...prev,
|
|
154
|
+
miniapps: prev.miniapps.map((item) => (item.id === row.id ? { ...item, ...row } : item))
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const handleScan = async (customRoots?: string[]): Promise<void> => {
|
|
160
|
+
if (scanPending) return
|
|
161
|
+
setScanPending(true)
|
|
162
|
+
setScanError(null)
|
|
163
|
+
try {
|
|
164
|
+
const result = await scanMiniapps(customRoots)
|
|
165
|
+
setScanRoots(Array.isArray(result.roots) ? result.roots : null)
|
|
166
|
+
const scannedRows = result.miniapps ?? []
|
|
167
|
+
setData((prev) => {
|
|
168
|
+
const base = prev ?? EMPTY_LIST
|
|
169
|
+
return {
|
|
170
|
+
...base,
|
|
171
|
+
miniapps: scannedRows,
|
|
172
|
+
total: result.totalSources ?? scannedRows.length,
|
|
173
|
+
appCount: new Set(scannedRows.map((row) => row.appid)).size,
|
|
174
|
+
customRoots: customRoots ?? base.customRoots,
|
|
175
|
+
lastScanAt: result.scannedAt
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
setError(null)
|
|
179
|
+
toast.success(
|
|
180
|
+
t('miniapp.scan.done', {
|
|
181
|
+
total: result.totalPackages,
|
|
182
|
+
added: result.added,
|
|
183
|
+
updated: result.updated,
|
|
184
|
+
unchanged: result.unchanged
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
if (result.errors.length > 0) {
|
|
188
|
+
toast.warning(t('miniapp.scan.errors', { count: result.errors.length }))
|
|
189
|
+
}
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
const message = toMiniappErrorMessage(cause)
|
|
192
|
+
setScanError(message)
|
|
193
|
+
toast.error(t('miniapp.scan.failed', { error: message }))
|
|
194
|
+
} finally {
|
|
195
|
+
setScanPending(false)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const handleAddRoot = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
200
|
+
event.preventDefault()
|
|
201
|
+
const directory = addRootValue.trim()
|
|
202
|
+
if (directory.length === 0 || addRootPending) return
|
|
203
|
+
setAddRootPending(true)
|
|
204
|
+
try {
|
|
205
|
+
const next = await setMiniappRoots([directory], 'append')
|
|
206
|
+
setRoots(next)
|
|
207
|
+
setAddRootValue('')
|
|
208
|
+
setAddRootOpen(false)
|
|
209
|
+
toast.success(t('miniapp.toast.rootAdded', { path: directory }))
|
|
210
|
+
await handleScan(next.customRoots)
|
|
211
|
+
} catch (cause) {
|
|
212
|
+
toast.error(t('miniapp.toast.rootFailed', { error: toMiniappErrorMessage(cause) }))
|
|
213
|
+
} finally {
|
|
214
|
+
setAddRootPending(false)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const handleUnpack = async (item: MiniappRow): Promise<void> => {
|
|
219
|
+
if (busy !== null) return
|
|
220
|
+
setBusy({ id: item.id, action: 'unpack' })
|
|
221
|
+
try {
|
|
222
|
+
const result = await unpackMiniapp(item.id)
|
|
223
|
+
replaceRow({ ...item, ...result.miniapp })
|
|
224
|
+
if (result.ok) {
|
|
225
|
+
toast.success(t('miniapp.toast.unpacked', { appid: item.appid }))
|
|
226
|
+
} else if (result.status === 'unsupported') {
|
|
227
|
+
toast.error(t('miniapp.toast.unpackUnsupported', { error: result.error }))
|
|
228
|
+
} else {
|
|
229
|
+
toast.error(t('miniapp.toast.unpackFailed', { error: result.error }))
|
|
230
|
+
}
|
|
231
|
+
} catch (cause) {
|
|
232
|
+
toast.error(t('miniapp.toast.unpackFailed', { error: toMiniappErrorMessage(cause) }))
|
|
233
|
+
} finally {
|
|
234
|
+
setBusy(null)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const handleAnalyze = async (item: MiniappRow): Promise<void> => {
|
|
239
|
+
if (busy !== null) return
|
|
240
|
+
setBusy({ id: item.id, action: 'analyze' })
|
|
241
|
+
try {
|
|
242
|
+
const result = await analyzeMiniapp(item.id)
|
|
243
|
+
replaceRow({ ...item, has_analysis: true })
|
|
244
|
+
toast.success(
|
|
245
|
+
t('miniapp.toast.analyzed', {
|
|
246
|
+
pages: result.analysis?.pages.length ?? 0,
|
|
247
|
+
endpoints: result.counts.endpoints,
|
|
248
|
+
secrets: result.counts.secrets
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
setSelectedId(item.id)
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
toast.error(t('miniapp.toast.analyzeFailed', { error: toMiniappErrorMessage(cause) }))
|
|
254
|
+
} finally {
|
|
255
|
+
setBusy(null)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const handleImport = async (item: MiniappRow): Promise<void> => {
|
|
260
|
+
if (busy !== null) return
|
|
261
|
+
setBusy({ id: item.id, action: 'import' })
|
|
262
|
+
try {
|
|
263
|
+
const result = await importMiniappAssets(item.id, {})
|
|
264
|
+
replaceRow({ ...item, has_analysis: true })
|
|
265
|
+
if (result.ok) {
|
|
266
|
+
toast.success(t('miniapp.toast.imported', { ...result.totals }))
|
|
267
|
+
} else {
|
|
268
|
+
toast.warning(t('miniapp.toast.importWarnings', { warnings: result.warnings.join('; ') }))
|
|
269
|
+
}
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
toast.error(t('miniapp.toast.importFailed', { error: toMiniappErrorMessage(cause) }))
|
|
272
|
+
} finally {
|
|
273
|
+
setBusy(null)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const handleOpenDir = async (item: MiniappRow): Promise<void> => {
|
|
278
|
+
if (busy !== null) return
|
|
279
|
+
setBusy({ id: item.id, action: 'open' })
|
|
280
|
+
try {
|
|
281
|
+
const result = await openMiniappDir(item.id)
|
|
282
|
+
toast.success(t('miniapp.toast.opened', { path: result.path }))
|
|
283
|
+
} catch (cause) {
|
|
284
|
+
toast.error(t('miniapp.toast.openFailed', { error: toMiniappErrorMessage(cause) }))
|
|
285
|
+
} finally {
|
|
286
|
+
setBusy(null)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const resetFilters = (): void => {
|
|
291
|
+
setSearch('')
|
|
292
|
+
setStatus('all')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const workspaceRoot = data?.workspaceRoot ?? ''
|
|
296
|
+
const customRootCount = roots?.customRoots?.length ?? data?.customRoots?.length ?? 0
|
|
297
|
+
const extraCount =
|
|
298
|
+
data === null
|
|
299
|
+
? 0
|
|
300
|
+
: data.miniapps.filter((row) => row.kind === 'plugin' || row.kind === 'library').length
|
|
301
|
+
const visibleMiniapps =
|
|
302
|
+
data === null
|
|
303
|
+
? []
|
|
304
|
+
: data.miniapps.filter(
|
|
305
|
+
(row) => showExtras || (row.kind !== 'plugin' && row.kind !== 'library')
|
|
306
|
+
)
|
|
307
|
+
const visibleCountLabel = t('miniapp.page.count', {
|
|
308
|
+
count: visibleMiniapps.length,
|
|
309
|
+
apps: new Set(visibleMiniapps.map((row) => row.appid)).size
|
|
310
|
+
})
|
|
311
|
+
const headerCountLabel =
|
|
312
|
+
data === null
|
|
313
|
+
? '—'
|
|
314
|
+
: !showExtras && extraCount > 0
|
|
315
|
+
? t('miniapp.page.countVisible', {
|
|
316
|
+
visible: visibleMiniapps.length,
|
|
317
|
+
total: data.total,
|
|
318
|
+
apps: data.appCount
|
|
319
|
+
})
|
|
320
|
+
: t('miniapp.page.count', { count: data.total, apps: data.appCount })
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<section
|
|
324
|
+
data-testid="miniapps-page"
|
|
325
|
+
className="mx-auto w-full max-w-[1440px] px-8 py-5"
|
|
326
|
+
>
|
|
327
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
328
|
+
<div className="min-w-0">
|
|
329
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
|
330
|
+
{t('miniapp.page.eyebrow')}
|
|
331
|
+
</p>
|
|
332
|
+
<div className="mt-1 flex items-baseline gap-2.5">
|
|
333
|
+
<h1 className="text-[18px] font-medium tracking-tight text-foreground">
|
|
334
|
+
{t('nav.miniapps')}
|
|
335
|
+
</h1>
|
|
336
|
+
<span className="font-mono text-[11px] tabular-nums text-foreground/70">
|
|
337
|
+
{headerCountLabel}
|
|
338
|
+
</span>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div className="mt-4 flex flex-wrap items-start gap-3 rounded-lg border border-border bg-sidebar px-4 py-3">
|
|
344
|
+
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background text-emerald-700">
|
|
345
|
+
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5" strokeWidth={1.75} />
|
|
346
|
+
</span>
|
|
347
|
+
<div className="min-w-0 flex-1">
|
|
348
|
+
<p className="text-[12px] font-medium text-foreground">{t('miniapp.banner.title')}</p>
|
|
349
|
+
<p className="mt-1 break-all font-mono text-[10px] text-muted-foreground/80">
|
|
350
|
+
{workspaceRoot.length > 0
|
|
351
|
+
? t('miniapp.banner.workspace', { path: workspaceRoot })
|
|
352
|
+
: null}
|
|
353
|
+
{customRootCount > 0
|
|
354
|
+
? `${workspaceRoot.length > 0 ? ' · ' : ''}${t('miniapp.banner.roots', {
|
|
355
|
+
count: customRootCount
|
|
356
|
+
})}`
|
|
357
|
+
: null}
|
|
358
|
+
</p>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{scanRoots !== null && scanRoots.length > 0 ? (
|
|
363
|
+
<div className="mt-2 flex flex-col gap-0.5 text-[10.5px] leading-relaxed text-muted-foreground">
|
|
364
|
+
<p className="break-all font-mono text-[10px] text-muted-foreground/80">
|
|
365
|
+
{t('miniapp.page.rootsSummary', {
|
|
366
|
+
summary: scanRoots
|
|
367
|
+
.map(
|
|
368
|
+
(root) =>
|
|
369
|
+
`${root.path.split(/[\\/]/).filter(Boolean).pop() ?? root.kind} ${
|
|
370
|
+
root.exists ? (root.filesFound ?? 0) : '—'
|
|
371
|
+
}`
|
|
372
|
+
)
|
|
373
|
+
.join(' · ')
|
|
374
|
+
})}
|
|
375
|
+
</p>
|
|
376
|
+
</div>
|
|
377
|
+
) : null}
|
|
378
|
+
|
|
379
|
+
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
380
|
+
<Button
|
|
381
|
+
variant="primary"
|
|
382
|
+
size="sm"
|
|
383
|
+
data-testid="miniapps-scan"
|
|
384
|
+
disabled={scanPending}
|
|
385
|
+
onClick={() => void handleScan()}
|
|
386
|
+
className="gap-1.5"
|
|
387
|
+
>
|
|
388
|
+
{scanPending ? (
|
|
389
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
390
|
+
) : (
|
|
391
|
+
<PackageSearch aria-hidden="true" className="h-3.5 w-3.5" />
|
|
392
|
+
)}
|
|
393
|
+
{scanPending ? t('miniapp.action.scanning') : t('miniapp.action.scan')}
|
|
394
|
+
</Button>
|
|
395
|
+
<Button
|
|
396
|
+
variant="outline"
|
|
397
|
+
size="sm"
|
|
398
|
+
data-testid="miniapps-refresh"
|
|
399
|
+
aria-label={t('common.refresh')}
|
|
400
|
+
disabled={loading && data === null}
|
|
401
|
+
onClick={() => setRefreshSeq((value) => value + 1)}
|
|
402
|
+
className="gap-1.5"
|
|
403
|
+
>
|
|
404
|
+
<RefreshCw aria-hidden="true" className="h-3.5 w-3.5" />
|
|
405
|
+
{t('common.refresh')}
|
|
406
|
+
</Button>
|
|
407
|
+
<Button
|
|
408
|
+
variant="outline"
|
|
409
|
+
size="sm"
|
|
410
|
+
data-testid="miniapps-add-root-toggle"
|
|
411
|
+
aria-expanded={addRootOpen}
|
|
412
|
+
onClick={() => setAddRootOpen((value) => !value)}
|
|
413
|
+
className="gap-1.5"
|
|
414
|
+
>
|
|
415
|
+
<FolderPlus aria-hidden="true" className="h-3.5 w-3.5" />
|
|
416
|
+
{t('miniapp.action.addRoot')}
|
|
417
|
+
</Button>
|
|
418
|
+
|
|
419
|
+
<div className="ml-auto flex flex-wrap items-center gap-2">
|
|
420
|
+
<Select
|
|
421
|
+
value={status}
|
|
422
|
+
onValueChange={(value) => setStatus(value as MiniappStatus | 'all')}
|
|
423
|
+
>
|
|
424
|
+
<SelectTrigger
|
|
425
|
+
data-testid="miniapps-status-filter"
|
|
426
|
+
aria-label={t('miniapp.filter.statusAria')}
|
|
427
|
+
className="h-8 w-[132px] text-[11px]"
|
|
428
|
+
>
|
|
429
|
+
<SelectValue />
|
|
430
|
+
</SelectTrigger>
|
|
431
|
+
<SelectContent>
|
|
432
|
+
<SelectItem value="all">{t('miniapp.filter.all')}</SelectItem>
|
|
433
|
+
{MINIAPP_STATUSES.map((value) => (
|
|
434
|
+
<SelectItem key={value} value={value}>
|
|
435
|
+
{t(`miniapp.status.${value}`)}
|
|
436
|
+
</SelectItem>
|
|
437
|
+
))}
|
|
438
|
+
</SelectContent>
|
|
439
|
+
</Select>
|
|
440
|
+
|
|
441
|
+
<Button
|
|
442
|
+
variant={showExtras ? 'subtle' : 'outline'}
|
|
443
|
+
size="sm"
|
|
444
|
+
data-testid="miniapps-show-extras"
|
|
445
|
+
aria-pressed={showExtras}
|
|
446
|
+
onClick={() => setShowExtras((value) => !value)}
|
|
447
|
+
className="gap-1.5"
|
|
448
|
+
>
|
|
449
|
+
<Boxes aria-hidden="true" className="h-3.5 w-3.5" />
|
|
450
|
+
{t(showExtras ? 'miniapp.filter.hideExtras' : 'miniapp.filter.showExtras')}
|
|
451
|
+
{extraCount > 0 ? (
|
|
452
|
+
<span className="font-mono text-[10px] tabular-nums">({extraCount})</span>
|
|
453
|
+
) : null}
|
|
454
|
+
</Button>
|
|
455
|
+
|
|
456
|
+
<div className="relative w-full max-w-[320px] sm:w-[280px]">
|
|
457
|
+
<Search
|
|
458
|
+
aria-hidden="true"
|
|
459
|
+
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
|
460
|
+
/>
|
|
461
|
+
<Input
|
|
462
|
+
ref={searchInputRef}
|
|
463
|
+
data-testid="miniapps-search"
|
|
464
|
+
aria-label={t('miniapp.search.ariaLabel')}
|
|
465
|
+
value={search}
|
|
466
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
467
|
+
placeholder={t('miniapp.search.placeholder')}
|
|
468
|
+
className="dshc-flat-input pl-9 pr-9"
|
|
469
|
+
/>
|
|
470
|
+
{search.length > 0 ? (
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
aria-label={t('miniapp.search.clear')}
|
|
474
|
+
onClick={() => setSearch('')}
|
|
475
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
|
|
476
|
+
>
|
|
477
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
478
|
+
</button>
|
|
479
|
+
) : null}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
{addRootOpen ? (
|
|
485
|
+
<form
|
|
486
|
+
onSubmit={(event) => void handleAddRoot(event)}
|
|
487
|
+
className="mt-3 flex flex-wrap items-start gap-2 rounded-lg border border-border bg-background px-3 py-2.5"
|
|
488
|
+
>
|
|
489
|
+
<div className="min-w-[240px] flex-1">
|
|
490
|
+
<Input
|
|
491
|
+
autoFocus
|
|
492
|
+
data-testid="miniapps-add-root-input"
|
|
493
|
+
aria-label={t('miniapp.action.addRoot')}
|
|
494
|
+
value={addRootValue}
|
|
495
|
+
onChange={(event) => setAddRootValue(event.target.value)}
|
|
496
|
+
placeholder={t('miniapp.addRoot.placeholder')}
|
|
497
|
+
className="dshc-flat-input h-8 font-mono text-[11.5px]"
|
|
498
|
+
/>
|
|
499
|
+
<p className="mt-1 text-[10.5px] leading-relaxed text-muted-foreground">
|
|
500
|
+
{t('miniapp.addRoot.hint')}
|
|
501
|
+
</p>
|
|
502
|
+
</div>
|
|
503
|
+
<Button
|
|
504
|
+
type="submit"
|
|
505
|
+
variant="primary"
|
|
506
|
+
size="sm"
|
|
507
|
+
data-testid="miniapps-add-root-submit"
|
|
508
|
+
disabled={addRootPending || addRootValue.trim().length === 0}
|
|
509
|
+
className="gap-1.5"
|
|
510
|
+
>
|
|
511
|
+
{addRootPending ? (
|
|
512
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
513
|
+
) : (
|
|
514
|
+
<FolderPlus aria-hidden="true" className="h-3.5 w-3.5" />
|
|
515
|
+
)}
|
|
516
|
+
{t('miniapp.action.addRootSubmit')}
|
|
517
|
+
</Button>
|
|
518
|
+
<Button
|
|
519
|
+
type="button"
|
|
520
|
+
variant="ghost"
|
|
521
|
+
size="sm"
|
|
522
|
+
onClick={() => {
|
|
523
|
+
setAddRootOpen(false)
|
|
524
|
+
setAddRootValue('')
|
|
525
|
+
}}
|
|
526
|
+
>
|
|
527
|
+
{t('miniapp.action.addRootCancel')}
|
|
528
|
+
</Button>
|
|
529
|
+
</form>
|
|
530
|
+
) : null}
|
|
531
|
+
|
|
532
|
+
{scanError !== null ? (
|
|
533
|
+
<p
|
|
534
|
+
role="alert"
|
|
535
|
+
className="mt-3 break-all rounded-md border border-red-200 bg-red-50 px-3 py-2 text-[11.5px] text-red-700"
|
|
536
|
+
>
|
|
537
|
+
{t('miniapp.scan.failed', { error: scanError })}
|
|
538
|
+
</p>
|
|
539
|
+
) : null}
|
|
540
|
+
|
|
541
|
+
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-background">
|
|
542
|
+
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-1.5">
|
|
543
|
+
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
|
544
|
+
{t('nav.miniapps')}
|
|
545
|
+
</span>
|
|
546
|
+
<span className="font-mono text-[10.5px] tabular-nums text-muted-foreground">
|
|
547
|
+
{data === null
|
|
548
|
+
? t('miniapp.page.loading')
|
|
549
|
+
: filtersActive
|
|
550
|
+
? t('miniapp.page.count', {
|
|
551
|
+
count: visibleMiniapps.length,
|
|
552
|
+
apps: new Set(visibleMiniapps.map((row) => row.appid)).size
|
|
553
|
+
})
|
|
554
|
+
: visibleCountLabel}
|
|
555
|
+
{!showExtras && extraCount > 0
|
|
556
|
+
? ` · ${t('miniapp.page.hiddenExtras', { count: extraCount })}`
|
|
557
|
+
: ''}
|
|
558
|
+
</span>
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
{error !== null ? (
|
|
562
|
+
<div role="alert" data-testid="miniapps-error" className="flex flex-wrap items-start gap-3 px-4 py-6">
|
|
563
|
+
<div className="min-w-0 flex-1">
|
|
564
|
+
<p className="text-[12.5px] font-medium text-red-700">{t('miniapp.error.title')}</p>
|
|
565
|
+
<p className="mt-1 break-all font-mono text-[11px] leading-relaxed text-red-700/80">
|
|
566
|
+
{error}
|
|
567
|
+
</p>
|
|
568
|
+
</div>
|
|
569
|
+
<Button
|
|
570
|
+
variant="outline"
|
|
571
|
+
size="sm"
|
|
572
|
+
onClick={() => {
|
|
573
|
+
setData(null)
|
|
574
|
+
setRetrySeq((value) => value + 1)
|
|
575
|
+
}}
|
|
576
|
+
>
|
|
577
|
+
{t('common.retry')}
|
|
578
|
+
</Button>
|
|
579
|
+
</div>
|
|
580
|
+
) : loading && data === null ? (
|
|
581
|
+
<div className="flex flex-col gap-2 p-4">
|
|
582
|
+
<Skeleton className="h-16 w-full" />
|
|
583
|
+
<Skeleton className="h-16 w-full" />
|
|
584
|
+
<Skeleton className="h-16 w-full" />
|
|
585
|
+
</div>
|
|
586
|
+
) : data === null || data.miniapps.length === 0 ? (
|
|
587
|
+
<EmptyState
|
|
588
|
+
icon={PackageSearch}
|
|
589
|
+
title={filtersActive ? t('miniapp.emptyFiltered.title') : t('miniapp.empty.title')}
|
|
590
|
+
description={
|
|
591
|
+
filtersActive
|
|
592
|
+
? t('miniapp.emptyFiltered.description')
|
|
593
|
+
: t('miniapp.empty.description')
|
|
594
|
+
}
|
|
595
|
+
>
|
|
596
|
+
{filtersActive ? (
|
|
597
|
+
<Button variant="outline" size="sm" onClick={resetFilters}>
|
|
598
|
+
{t('miniapp.emptyFiltered.reset')}
|
|
599
|
+
</Button>
|
|
600
|
+
) : (
|
|
601
|
+
<Button
|
|
602
|
+
variant="primary"
|
|
603
|
+
size="sm"
|
|
604
|
+
disabled={scanPending}
|
|
605
|
+
onClick={() => void handleScan()}
|
|
606
|
+
className={cn('gap-1.5')}
|
|
607
|
+
>
|
|
608
|
+
{scanPending ? (
|
|
609
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
610
|
+
) : (
|
|
611
|
+
<PackageSearch aria-hidden="true" className="h-3.5 w-3.5" />
|
|
612
|
+
)}
|
|
613
|
+
{t('miniapp.empty.cta')}
|
|
614
|
+
</Button>
|
|
615
|
+
)}
|
|
616
|
+
</EmptyState>
|
|
617
|
+
) : visibleMiniapps.length === 0 && filtersActive ? (
|
|
618
|
+
<EmptyState
|
|
619
|
+
icon={Search}
|
|
620
|
+
title={t('miniapp.emptyFiltered.title')}
|
|
621
|
+
description={t('miniapp.emptyFiltered.description')}
|
|
622
|
+
>
|
|
623
|
+
<Button variant="outline" size="sm" onClick={resetFilters}>
|
|
624
|
+
{t('miniapp.emptyFiltered.reset')}
|
|
625
|
+
</Button>
|
|
626
|
+
</EmptyState>
|
|
627
|
+
) : visibleMiniapps.length === 0 ? (
|
|
628
|
+
<EmptyState
|
|
629
|
+
icon={Boxes}
|
|
630
|
+
title={t('miniapp.emptyExtras.title')}
|
|
631
|
+
description={t('miniapp.emptyExtras.description')}
|
|
632
|
+
>
|
|
633
|
+
<Button variant="outline" size="sm" onClick={() => setShowExtras(true)}>
|
|
634
|
+
{t('miniapp.emptyExtras.cta')}
|
|
635
|
+
</Button>
|
|
636
|
+
</EmptyState>
|
|
637
|
+
) : (
|
|
638
|
+
<ul>
|
|
639
|
+
{visibleMiniapps.map((item) => (
|
|
640
|
+
<MiniappCard
|
|
641
|
+
key={item.id}
|
|
642
|
+
item={item}
|
|
643
|
+
busy={busy !== null && busy.id === item.id}
|
|
644
|
+
busyAction={busy !== null && busy.id === item.id ? busy.action : null}
|
|
645
|
+
onOpen={(row) => setSelectedId(row.id)}
|
|
646
|
+
onUnpack={(row) => void handleUnpack(row)}
|
|
647
|
+
onAnalyze={(row) => void handleAnalyze(row)}
|
|
648
|
+
onImport={(row) => void handleImport(row)}
|
|
649
|
+
onOpenDir={(row) => void handleOpenDir(row)}
|
|
650
|
+
/>
|
|
651
|
+
))}
|
|
652
|
+
</ul>
|
|
653
|
+
)}
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
<MiniappDetail
|
|
657
|
+
miniappId={selectedId}
|
|
658
|
+
onClose={() => setSelectedId(null)}
|
|
659
|
+
onChanged={() => setRefreshSeq((value) => value + 1)}
|
|
660
|
+
/>
|
|
661
|
+
</section>
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|