@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,411 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { ArrowRight, Braces, CornerDownRight, Loader2, Play } from 'lucide-react'
3
+ import type { Vulnerability } from '@shared/types'
4
+ import type { RepeaterSendResult } from '@shared/repeater'
5
+ import { Button } from '@renderer/components/ui/button'
6
+ import { Checkbox } from '@renderer/components/ui/checkbox'
7
+ import { Skeleton } from '@renderer/components/ui/skeleton'
8
+ import { useI18n } from '@renderer/i18n'
9
+ import { getApi } from '@renderer/lib/api'
10
+ import {
11
+ formatRequestBody,
12
+ parseRawRequest,
13
+ stripDerivedHeaders
14
+ } from '@renderer/lib/repeaterRaw'
15
+ import {
16
+ defaultRequestIndex,
17
+ extractRepeaterRequests,
18
+ resolveRepeaterUrl
19
+ } from '@renderer/lib/retestRequest'
20
+ import {
21
+ buildHtmlPreview,
22
+ headerValue,
23
+ isHtmlResponse,
24
+ isRedirectResponse,
25
+ prettyJsonBody,
26
+ statusTone
27
+ } from '@renderer/lib/retestResponse'
28
+ import { cn } from '@renderer/lib/utils'
29
+
30
+ interface YakitSendPanelProps {
31
+ vuln: Vulnerability | null
32
+ loading: boolean
33
+ }
34
+
35
+ function describeSendError(cause: unknown, connectionRefused: string): string {
36
+ const raw = cause instanceof Error ? cause.message : String(cause)
37
+ const cleaned = raw
38
+ .replace(/^Error invoking remote method '[^']+':\s*/, '')
39
+ .replace(/^Error:\s*/, '')
40
+ .trim()
41
+ if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET/i.test(cleaned)) return connectionRefused
42
+ return cleaned === '' ? connectionRefused : cleaned
43
+ }
44
+
45
+ /**
46
+ * Yakit-style manual HTTP sender: the request editor fills the left half with
47
+ * the send button at its top-left, the response viewer fills the right half, and
48
+ * both panes use the full available height. This is the plain Web Fuzzer module
49
+ * (no payload markers, attack modes, batch jobs or result table); every request
50
+ * still goes through the retest proxy and the finding origin allowlist.
51
+ */
52
+ export function YakitSendPanel({ vuln, loading }: YakitSendPanelProps) {
53
+ const { t } = useI18n()
54
+ const [raw, setRaw] = useState('')
55
+ const [step, setStep] = useState(0)
56
+ const [sending, setSending] = useState(false)
57
+ const [followRedirects, setFollowRedirects] = useState(false)
58
+ const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
59
+ const [error, setError] = useState<string | null>(null)
60
+ const [result, setResult] = useState<RepeaterSendResult | null>(null)
61
+
62
+ const requests = useMemo(() => (vuln === null ? [] : extractRepeaterRequests(vuln)), [vuln])
63
+
64
+ useEffect(() => {
65
+ setResult(null)
66
+ setError(null)
67
+ if (vuln === null) {
68
+ setRaw('')
69
+ setStep(0)
70
+ return
71
+ }
72
+ const nextStep = defaultRequestIndex(vuln, requests)
73
+ setStep(nextStep)
74
+ const initial = requests[nextStep]?.raw ?? ''
75
+ setRaw(formatRequestBody(initial) ?? initial)
76
+ // Reset only when the selected vulnerability changes; user edits must survive.
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [vuln?.id])
79
+
80
+ const selectStep = (nextStep: number): void => {
81
+ const next = requests[nextStep]
82
+ if (next === undefined) return
83
+ setStep(nextStep)
84
+ setRaw(formatRequestBody(next.raw) ?? next.raw)
85
+ setResult(null)
86
+ setError(null)
87
+ }
88
+
89
+ /** Pretty-print the JSON request body while keeping the request line/headers. */
90
+ const formatRequest = (): void => {
91
+ const formatted = formatRequestBody(raw)
92
+ if (formatted === null) {
93
+ setError(t('retest.repeater.formatRequestFailed'))
94
+ return
95
+ }
96
+ setRaw(formatted)
97
+ setError(null)
98
+ }
99
+
100
+ const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
101
+ const responseIsRedirect =
102
+ result !== null && isRedirectResponse(result.response.status, result.response.headers)
103
+ const responseIsHtml =
104
+ result !== null && isHtmlResponse(result.response.body, result.response.headers)
105
+ const formattedJson = useMemo(
106
+ () =>
107
+ result === null
108
+ ? null
109
+ : prettyJsonBody(result.response.body, headerValue(result.response.headers, 'content-type')),
110
+ [result]
111
+ )
112
+ const htmlPreview = useMemo(
113
+ () =>
114
+ result === null || vuln === null
115
+ ? ''
116
+ : buildHtmlPreview(result.response.body, vuln.target_url),
117
+ [result, vuln]
118
+ )
119
+ /** Status line + response headers, shared by the raw and formatted views. */
120
+ const responseHead = useMemo(() => {
121
+ if (result === null) return ''
122
+ const lines = [`HTTP/1.1 ${result.response.status}`]
123
+ for (const [name, value] of Object.entries(result.response.headers)) {
124
+ lines.push(`${name}: ${value}`)
125
+ }
126
+ return lines.join('\n')
127
+ }, [result])
128
+ /** Raw response as one block: status + headers + blank line + body. */
129
+ const rawResponse = useMemo(
130
+ () => (result === null ? '' : `${responseHead}\n\n${result.response.body}`),
131
+ [result, responseHead]
132
+ )
133
+ /** JSON view keeps the status/header block and only formats the body. */
134
+ const jsonResponse = useMemo(
135
+ () => (formattedJson === null ? '' : `${responseHead}\n\n${formattedJson}`),
136
+ [formattedJson, responseHead]
137
+ )
138
+
139
+ const send = async (): Promise<void> => {
140
+ if (vuln === null || sending) return
141
+ const api = getApi()
142
+ if (!api) {
143
+ setError(t('common.apiUnavailable'))
144
+ return
145
+ }
146
+ setSending(true)
147
+ setError(null)
148
+ try {
149
+ const parsed = parseRawRequest(raw)
150
+ const response = await api.sendRepeaterRequest({
151
+ vulnId: vuln.id,
152
+ method: parsed.method,
153
+ url: resolveRepeaterUrl(parsed.url, vuln.target_url),
154
+ headers: stripDerivedHeaders(parsed.headers),
155
+ ...(parsed.body === '' ? {} : { body: parsed.body }),
156
+ followRedirects
157
+ })
158
+ setResult(response)
159
+ // Formatted JSON is the default response view; raw stays one click away.
160
+ setBodyView(
161
+ prettyJsonBody(response.response.body, headerValue(response.response.headers, 'content-type')) !== null
162
+ ? 'pretty'
163
+ : 'raw'
164
+ )
165
+ } catch (cause) {
166
+ setError(describeSendError(cause, t('retest.repeater.connectionFailed')))
167
+ setResult(null)
168
+ } finally {
169
+ setSending(false)
170
+ }
171
+ }
172
+
173
+ if (loading && vuln === null) {
174
+ return (
175
+ <div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
176
+ <Skeleton className="h-full min-h-[360px] w-full" />
177
+ <Skeleton className="h-full min-h-[360px] w-full" />
178
+ </div>
179
+ )
180
+ }
181
+
182
+ if (vuln === null) {
183
+ return (
184
+ <div className="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-dashed border-border bg-background/60 text-[12px] text-muted-foreground">
185
+ {t('retest.repeater.empty')}
186
+ </div>
187
+ )
188
+ }
189
+
190
+ return (
191
+ <div
192
+ data-testid="fuzzer-panel"
193
+ className="grid min-h-0 flex-1 items-stretch gap-3 lg:grid-cols-2"
194
+ >
195
+ {/* ------------------------------------------------------------- request */}
196
+ <section
197
+ data-testid="fuzzer-request-pane"
198
+ aria-label={t('fuzzer.request.paneAria')}
199
+ className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background"
200
+ >
201
+ <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
202
+ <Button
203
+ size="sm"
204
+ data-testid="fuzzer-send"
205
+ onClick={() => void send()}
206
+ disabled={sending || raw.trim() === ''}
207
+ className="gap-1.5"
208
+ >
209
+ {sending ? (
210
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin poc-spinner" />
211
+ ) : (
212
+ <Play aria-hidden="true" className="h-3.5 w-3.5" />
213
+ )}
214
+ {sending ? t('retest.repeater.sending') : t('retest.repeater.send')}
215
+ </Button>
216
+ <label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-[10.5px] text-muted-foreground">
217
+ <Checkbox
218
+ data-testid="fuzzer-follow-redirects"
219
+ checked={followRedirects}
220
+ onChange={(event) => setFollowRedirects(event.target.checked)}
221
+ />
222
+ {t('retest.repeater.followRedirects')}
223
+ </label>
224
+ <Button
225
+ variant="outline"
226
+ size="xs"
227
+ data-testid="fuzzer-format-request"
228
+ title={t('retest.repeater.formatRequestTitle')}
229
+ onClick={formatRequest}
230
+ className="shrink-0 gap-1"
231
+ >
232
+ <Braces aria-hidden="true" className="h-3 w-3" />
233
+ {t('retest.repeater.formatRequest')}
234
+ </Button>
235
+ <span className="shrink-0 rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground">
236
+ {vuln.id}
237
+ </span>
238
+ {requests.length > 0 ? (
239
+ <div
240
+ className="ml-auto flex min-w-0 items-center gap-1 overflow-x-auto"
241
+ role="tablist"
242
+ aria-label={t('retest.repeater.stepsAria')}
243
+ >
244
+ {requests.map((request, index) => (
245
+ <button
246
+ key={`${index}-${request.label}`}
247
+ type="button"
248
+ role="tab"
249
+ aria-selected={index === step}
250
+ title={request.label}
251
+ data-testid={`fuzzer-request-step-${index}`}
252
+ onClick={() => selectStep(index)}
253
+ className={cn(
254
+ 'h-6 shrink-0 rounded border px-2 font-mono text-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
255
+ index === step
256
+ ? 'border-accent/50 bg-accent-soft font-medium text-accent'
257
+ : 'border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
258
+ )}
259
+ >
260
+ {t('retest.repeater.stepN', { n: index + 1 })}
261
+ </button>
262
+ ))}
263
+ </div>
264
+ ) : null}
265
+ <span className={cn('shrink-0 font-mono text-[9.5px] tabular-nums text-muted-foreground', requests.length === 0 && 'ml-auto')}>
266
+ {t('retest.repeater.bytes', { count: rawBytes })}
267
+ </span>
268
+ </div>
269
+
270
+ <div className="flex min-h-0 flex-1 flex-col p-3">
271
+ <textarea
272
+ data-testid="fuzzer-request"
273
+ aria-label={t('retest.repeater.requestAria')}
274
+ value={raw}
275
+ spellCheck={false}
276
+ onChange={(event) => setRaw(event.target.value)}
277
+ className="min-h-0 w-full flex-1 resize-none rounded-lg border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground caret-accent placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
278
+ />
279
+ {error !== null ? (
280
+ <p
281
+ role="alert"
282
+ data-testid="fuzzer-send-error"
283
+ className="mt-2 rounded-md border border-red-200 bg-red-50 px-2.5 py-1.5 text-[10.5px] leading-relaxed text-red-700"
284
+ >
285
+ {error}
286
+ </p>
287
+ ) : null}
288
+ </div>
289
+ </section>
290
+
291
+ {/* ------------------------------------------------------------ response */}
292
+ <section
293
+ data-testid="fuzzer-response-pane"
294
+ aria-label={t('fuzzer.response.aria')}
295
+ className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background"
296
+ >
297
+ <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
298
+ <ArrowRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
299
+ <span className="shrink-0 text-[12px] font-medium text-foreground">{t('retest.repeater.responseTitle')}</span>
300
+ {result !== null ? (
301
+ <>
302
+ <span
303
+ data-testid="fuzzer-response-status"
304
+ className={`rounded border px-1.5 py-0.5 font-mono text-[10px] tabular-nums ${statusTone(result.response.status)}`}
305
+ >
306
+ {result.response.status}
307
+ </span>
308
+ <span className="font-mono text-[9.5px] tabular-nums text-muted-foreground">
309
+ {result.response.durationMs} ms · {result.response.body.length} B
310
+ </span>
311
+ {result.response.truncated ? (
312
+ <span className="text-[9.5px] text-amber-700">{t('retest.repeater.truncated')}</span>
313
+ ) : null}
314
+ {responseIsRedirect ? (
315
+ <Button
316
+ variant="outline"
317
+ size="xs"
318
+ onClick={() => void send()}
319
+ disabled={sending}
320
+ className="gap-1 text-[10.5px]"
321
+ >
322
+ <CornerDownRight aria-hidden="true" className="h-3 w-3" />
323
+ {t('retest.repeater.followRedirect')}
324
+ </Button>
325
+ ) : null}
326
+ {formattedJson !== null || responseIsHtml ? (
327
+ <div className="ml-auto flex shrink-0 items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
328
+ <button
329
+ type="button"
330
+ data-testid="fuzzer-view-raw"
331
+ onClick={() => setBodyView('raw')}
332
+ className={cn(
333
+ 'rounded px-1.5 py-0.5 text-[10px] transition-colors',
334
+ bodyView === 'raw'
335
+ ? 'bg-background font-medium text-foreground shadow-sm'
336
+ : 'text-muted-foreground hover:text-foreground'
337
+ )}
338
+ >
339
+ {t('retest.repeater.viewRaw')}
340
+ </button>
341
+ {formattedJson !== null ? (
342
+ <button
343
+ type="button"
344
+ data-testid="fuzzer-view-json"
345
+ onClick={() => setBodyView('pretty')}
346
+ className={cn(
347
+ 'rounded px-1.5 py-0.5 text-[10px] transition-colors',
348
+ bodyView === 'pretty'
349
+ ? 'bg-background font-medium text-foreground shadow-sm'
350
+ : 'text-muted-foreground hover:text-foreground'
351
+ )}
352
+ >
353
+ {t('retest.repeater.formatJson')}
354
+ </button>
355
+ ) : null}
356
+ {responseIsHtml ? (
357
+ <button
358
+ type="button"
359
+ data-testid="fuzzer-view-html"
360
+ onClick={() => setBodyView('html')}
361
+ className={cn(
362
+ 'rounded px-1.5 py-0.5 text-[10px] transition-colors',
363
+ bodyView === 'html'
364
+ ? 'bg-background font-medium text-foreground shadow-sm'
365
+ : 'text-muted-foreground hover:text-foreground'
366
+ )}
367
+ >
368
+ {t('retest.repeater.viewHtml')}
369
+ </button>
370
+ ) : null}
371
+ </div>
372
+ ) : null}
373
+ </>
374
+ ) : null}
375
+ </div>
376
+
377
+ {result === null ? (
378
+ <p className="flex flex-1 items-center justify-center px-3 text-[11.5px] text-muted-foreground">
379
+ {t('retest.repeater.emptyResponse')}
380
+ </p>
381
+ ) : (
382
+ <div className="flex min-h-0 flex-1 flex-col p-3">
383
+ {responseIsHtml && bodyView === 'html' ? (
384
+ <iframe
385
+ title={t('retest.repeater.htmlPreview')}
386
+ sandbox=""
387
+ referrerPolicy="no-referrer"
388
+ srcDoc={htmlPreview}
389
+ className="min-h-0 w-full flex-1 rounded-md border border-border bg-white"
390
+ />
391
+ ) : formattedJson !== null && bodyView === 'pretty' ? (
392
+ <pre
393
+ data-testid="fuzzer-response-json"
394
+ className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground"
395
+ >
396
+ {jsonResponse}
397
+ </pre>
398
+ ) : (
399
+ <pre
400
+ data-testid="fuzzer-response-body"
401
+ className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground"
402
+ >
403
+ {rawResponse.slice(0, 200_000)}
404
+ </pre>
405
+ )}
406
+ </div>
407
+ )}
408
+ </section>
409
+ </div>
410
+ )
411
+ }
@@ -0,0 +1,187 @@
1
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
2
+ import { ArrowUp, Folder, FolderCheck, Loader2 } from 'lucide-react'
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { Button } from '@renderer/components/ui/button'
5
+ import { useI18n } from '@renderer/i18n'
6
+ import { cn } from '@renderer/lib/utils'
7
+ import { getPortalContainer } from '../../../dsh/portal'
8
+
9
+ interface DirectoryListing {
10
+ path: string
11
+ parent: string | null
12
+ directories: Array<{ name: string; path: string }>
13
+ home: string
14
+ }
15
+
16
+ interface DirectoryPickerDialogProps {
17
+ open: boolean
18
+ initialPath?: string
19
+ onOpenChange: (open: boolean) => void
20
+ onSelect: (path: string) => void
21
+ }
22
+
23
+ async function listDirectories(path: string): Promise<DirectoryListing> {
24
+ const query = path.trim() === '' ? '' : `?path=${encodeURIComponent(path.trim())}`
25
+ const response = await fetch(`/cortex-api/cortex/fs/list${query}`)
26
+ const payload = (await response.json().catch(() => null)) as
27
+ | (DirectoryListing & { error?: string })
28
+ | null
29
+ if (!response.ok) {
30
+ throw new Error(payload?.error || `HTTP ${response.status}`)
31
+ }
32
+ return payload as DirectoryListing
33
+ }
34
+
35
+ /**
36
+ * Server-backed folder picker: the browser cannot reveal absolute directory
37
+ * paths for a text export dialog, so the Host lists folders and this dialog
38
+ * returns the chosen absolute path.
39
+ */
40
+ export function DirectoryPickerDialog({
41
+ open,
42
+ initialPath = '',
43
+ onOpenChange,
44
+ onSelect
45
+ }: DirectoryPickerDialogProps) {
46
+ const { t } = useI18n()
47
+ const [current, setCurrent] = useState('')
48
+ const [parent, setParent] = useState<string | null>(null)
49
+ const [directories, setDirectories] = useState<Array<{ name: string; path: string }>>([])
50
+ const [manual, setManual] = useState('')
51
+ const [loading, setLoading] = useState(false)
52
+ const [error, setError] = useState<string | null>(null)
53
+
54
+ const load = useCallback(
55
+ async (target: string) => {
56
+ setLoading(true)
57
+ setError(null)
58
+ try {
59
+ const listing = await listDirectories(target)
60
+ setCurrent(listing.path)
61
+ setParent(listing.parent)
62
+ setDirectories(listing.directories)
63
+ setManual(listing.path)
64
+ } catch (cause) {
65
+ setError(cause instanceof Error ? cause.message : String(cause))
66
+ } finally {
67
+ setLoading(false)
68
+ }
69
+ },
70
+ []
71
+ )
72
+
73
+ useEffect(() => {
74
+ if (!open) return
75
+ void load(initialPath)
76
+ // Only reload when the dialog opens; user navigation is handled locally.
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [open])
79
+
80
+ return (
81
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
82
+ <DialogPrimitive.Portal container={getPortalContainer()}>
83
+ <DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[80] bg-[hsl(224_18%_10%/0.3)]" />
84
+ <DialogPrimitive.Content
85
+ data-testid="directory-picker-dialog"
86
+ className="fixed left-1/2 top-1/2 z-[80] flex w-[min(620px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
87
+ >
88
+ <DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
89
+ {t('vuln.dirPicker.title')}
90
+ </DialogPrimitive.Title>
91
+
92
+ <div className="mt-3 flex items-center gap-1.5">
93
+ <input
94
+ data-testid="directory-picker-path"
95
+ value={manual}
96
+ spellCheck={false}
97
+ onChange={(event) => setManual(event.target.value)}
98
+ onKeyDown={(event) => {
99
+ if (event.key === 'Enter') void load(manual)
100
+ }}
101
+ className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
102
+ />
103
+ <Button
104
+ variant="outline"
105
+ size="xs"
106
+ onClick={() => void load(manual)}
107
+ disabled={loading}
108
+ className="h-8 gap-1 px-2.5"
109
+ >
110
+ {t('vuln.dirPicker.go')}
111
+ </Button>
112
+ <Button
113
+ variant="outline"
114
+ size="xs"
115
+ data-testid="directory-picker-up"
116
+ onClick={() => {
117
+ if (parent !== null) void load(parent)
118
+ }}
119
+ disabled={loading || parent === null}
120
+ className="h-8 gap-1 px-2.5"
121
+ >
122
+ <ArrowUp aria-hidden="true" className="h-3 w-3" />
123
+ {t('vuln.dirPicker.up')}
124
+ </Button>
125
+ </div>
126
+
127
+ <div className="mt-3 max-h-[320px] min-h-[180px] overflow-y-auto rounded-lg border border-border">
128
+ {loading ? (
129
+ <p className="flex h-[180px] items-center justify-center gap-2 text-[11.5px] text-muted-foreground">
130
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
131
+ {t('vuln.dirPicker.loading')}
132
+ </p>
133
+ ) : error !== null ? (
134
+ <p className="p-3 text-[11.5px] text-red-700">
135
+ {t('vuln.dirPicker.error', { error })}
136
+ </p>
137
+ ) : directories.length === 0 ? (
138
+ <p className="flex h-[180px] items-center justify-center text-[11.5px] text-muted-foreground">
139
+ {t('vuln.dirPicker.empty')}
140
+ </p>
141
+ ) : (
142
+ <ul className="divide-y divide-border">
143
+ {directories.map((entry) => (
144
+ <li key={entry.path}>
145
+ <button
146
+ type="button"
147
+ data-testid="directory-picker-entry"
148
+ onClick={() => void load(entry.path)}
149
+ className={cn(
150
+ 'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
151
+ 'hover:bg-sidebar focus-visible:bg-accent-soft focus-visible:outline-none'
152
+ )}
153
+ >
154
+ <Folder aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
155
+ <span className="truncate text-[12px] text-foreground">{entry.name}</span>
156
+ </button>
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ )}
161
+ </div>
162
+
163
+ <div className="mt-4 flex items-center justify-between gap-2">
164
+ <span className="min-w-0 truncate font-mono text-[10.5px] text-muted-foreground" title={current}>
165
+ {current}
166
+ </span>
167
+ <div className="flex shrink-0 items-center gap-2">
168
+ <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
169
+ {t('common.cancel')}
170
+ </Button>
171
+ <Button
172
+ size="sm"
173
+ data-testid="directory-picker-select"
174
+ disabled={loading || error !== null || current === ''}
175
+ onClick={() => onSelect(current)}
176
+ className="gap-1.5"
177
+ >
178
+ <FolderCheck aria-hidden="true" className="h-3.5 w-3.5" />
179
+ {t('vuln.dirPicker.select')}
180
+ </Button>
181
+ </div>
182
+ </div>
183
+ </DialogPrimitive.Content>
184
+ </DialogPrimitive.Portal>
185
+ </DialogPrimitive.Root>
186
+ )
187
+ }