@lievclouds/dsh-cortex 0.4.3 → 0.5.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lievclouds/dsh-cortex",
3
- "version": "0.4.3",
3
+ "version": "0.5.4",
4
4
  "description": "Cortex workbench for DeepSeek Harness: the original Cortex UI (vulnerabilities, assets, internal operations, Memory Nebula, snapshots, retest) as a native DSH panel, plus 12 native agent tools so DSH conversations use the local Cortex SQLite without MCP.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -102,5 +102,9 @@
102
102
  "tailwindcss": "3.4.19",
103
103
  "three": "^0.187.0",
104
104
  "zod": "4.6.7"
105
+ },
106
+ "dependencies": {
107
+ "docx": "^9.15.3",
108
+ "pdfkit": "^0.20.2"
105
109
  }
106
110
  }
@@ -112,10 +112,13 @@ export function createCortexApi(): CortexApi {
112
112
  patch(`/vulns/${encodeURIComponent(input.id)}/status`, { status: input.status }),
113
113
  deleteVulnerability: (id) => del(`/vulns/${encodeURIComponent(id)}`),
114
114
  deleteVulnerabilities: (ids) => post('/vulns/delete-many', { ids }),
115
- exportVulnerabilities: async (ids, outputDir) => {
115
+ exportVulnerabilities: async (ids, options) => {
116
+ const outputDir = options?.outputDir?.trim()
117
+ const format = options?.format
116
118
  const response = await post('/vulns/export', {
117
119
  ids,
118
- ...(outputDir && outputDir.trim() !== '' ? { outputDir: outputDir.trim() } : {}),
120
+ ...(outputDir ? { outputDir } : {}),
121
+ ...(format ? { format } : {}),
119
122
  })
120
123
  const files = Array.isArray(response?.files) ? response.files : []
121
124
  const savedPaths = Array.isArray(response?.savedPaths) ? response.savedPaths : []
@@ -397,7 +397,15 @@ export function AssetDetail({
397
397
  if (!next) onClose()
398
398
  }}
399
399
  >
400
- <SheetContent data-testid="asset-sheet">
400
+ <SheetContent
401
+ data-testid="asset-sheet"
402
+ onInteractOutside={(event) => {
403
+ if (deleteOpen) event.preventDefault()
404
+ }}
405
+ onEscapeKeyDown={(event) => {
406
+ if (deleteOpen) event.preventDefault()
407
+ }}
408
+ >
401
409
  <div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-6">
402
410
  <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
403
411
  {t('asset.page.eyebrow')}
@@ -11,6 +11,7 @@ import {
11
11
  extractRepeaterRequests,
12
12
  resolveRepeaterUrl
13
13
  } from '@renderer/lib/retestRequest'
14
+ import { prettyJsonBody } from '@renderer/lib/retestResponse'
14
15
 
15
16
  interface RepeaterPanelProps {
16
17
  vuln: Vulnerability | null
@@ -24,6 +25,21 @@ interface ParsedRawRequest {
24
25
  body: string
25
26
  }
26
27
 
28
+ /**
29
+ * Protocol-level headers copied from a report are re-derived by the outbound
30
+ * request (Host from the URL, length/connection by the HTTP client). Dropping
31
+ * them here keeps raw requests sendable without a validation error.
32
+ */
33
+ const DERIVED_HEADERS = new Set([
34
+ 'host',
35
+ 'content-length',
36
+ 'connection',
37
+ 'transfer-encoding',
38
+ 'upgrade',
39
+ 'keep-alive',
40
+ 'proxy-connection'
41
+ ])
42
+
27
43
  /** Parse a Burp-like raw request into the typed repeater payload. */
28
44
  function parseRawRequest(text: string): ParsedRawRequest {
29
45
  const lines = text.replace(/\r\n/g, '\n').split('\n')
@@ -108,7 +124,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
108
124
  const [raw, setRaw] = useState('')
109
125
  const [step, setStep] = useState(0)
110
126
  const [sending, setSending] = useState(false)
111
- const [bodyView, setBodyView] = useState<'raw' | 'html'>('raw')
127
+ const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
112
128
  const [error, setError] = useState<string | null>(null)
113
129
  const [result, setResult] = useState<RepeaterSendResult | null>(null)
114
130
 
@@ -141,6 +157,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
141
157
  const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
142
158
  const responseIsRedirect = result !== null && isRedirectResponse(result)
143
159
  const responseIsHtml = result !== null && isHtmlResponse(result)
160
+ const formattedJson = useMemo(
161
+ () =>
162
+ result === null
163
+ ? null
164
+ : prettyJsonBody(result.response.body, headerValue(result.response.headers, 'content-type')),
165
+ [result]
166
+ )
144
167
  const htmlPreview = useMemo(
145
168
  () => (result === null || vuln === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
146
169
  [result, vuln]
@@ -157,11 +180,14 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
157
180
  setError(null)
158
181
  try {
159
182
  const parsed = parseRawRequest(raw)
183
+ const headers = Object.fromEntries(
184
+ Object.entries(parsed.headers).filter(([name]) => !DERIVED_HEADERS.has(name.toLowerCase()))
185
+ )
160
186
  const response = await api.sendRepeaterRequest({
161
187
  vulnId: vuln.id,
162
188
  method: parsed.method,
163
189
  url: resolveRepeaterUrl(parsed.url, vuln.target_url),
164
- headers: parsed.headers,
190
+ headers,
165
191
  ...(parsed.body === '' ? {} : { body: parsed.body }),
166
192
  followRedirects
167
193
  })
@@ -288,7 +314,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
288
314
  {t('retest.repeater.followRedirect')}
289
315
  </Button>
290
316
  ) : null}
291
- {responseIsHtml ? (
317
+ {formattedJson !== null || responseIsHtml ? (
292
318
  <div className="ml-auto flex items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
293
319
  <button
294
320
  type="button"
@@ -302,18 +328,34 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
302
328
  >
303
329
  {t('retest.repeater.viewRaw')}
304
330
  </button>
305
- <button
306
- type="button"
307
- data-testid="retest-repeater-view-html"
308
- onClick={() => setBodyView('html')}
309
- className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
310
- bodyView === 'html'
311
- ? 'bg-background font-medium text-foreground shadow-sm'
312
- : 'text-muted-foreground hover:text-foreground'
313
- }`}
314
- >
315
- {t('retest.repeater.viewHtml')}
316
- </button>
331
+ {formattedJson !== null ? (
332
+ <button
333
+ type="button"
334
+ data-testid="retest-repeater-view-json"
335
+ onClick={() => setBodyView('pretty')}
336
+ className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
337
+ bodyView === 'pretty'
338
+ ? 'bg-background font-medium text-foreground shadow-sm'
339
+ : 'text-muted-foreground hover:text-foreground'
340
+ }`}
341
+ >
342
+ {t('retest.repeater.formatJson')}
343
+ </button>
344
+ ) : null}
345
+ {responseIsHtml ? (
346
+ <button
347
+ type="button"
348
+ data-testid="retest-repeater-view-html"
349
+ onClick={() => setBodyView('html')}
350
+ className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
351
+ bodyView === 'html'
352
+ ? 'bg-background font-medium text-foreground shadow-sm'
353
+ : 'text-muted-foreground hover:text-foreground'
354
+ }`}
355
+ >
356
+ {t('retest.repeater.viewHtml')}
357
+ </button>
358
+ ) : null}
317
359
  </div>
318
360
  ) : null}
319
361
  </>
@@ -338,6 +380,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
338
380
  srcDoc={htmlPreview}
339
381
  className="h-[min(60vh,520px)] min-h-[320px] w-full rounded-md border border-border bg-white"
340
382
  />
383
+ ) : formattedJson !== null && bodyView === 'pretty' ? (
384
+ <pre
385
+ data-testid="retest-repeater-json"
386
+ className="whitespace-pre-wrap break-words rounded-md border border-border bg-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)]"
387
+ >
388
+ {formattedJson}
389
+ </pre>
341
390
  ) : (
342
391
  <pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)]">
343
392
  {result.response.body.slice(0, 200_000)}
@@ -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
+ }
@@ -1,11 +1,15 @@
1
1
  import * as DialogPrimitive from '@radix-ui/react-dialog'
2
2
  import { Download, FolderOpen, Loader2 } from 'lucide-react'
3
3
  import { useEffect, useState } from 'react'
4
+ import type { ExportReportFormat } from '@shared/types'
4
5
  import { Button } from '@renderer/components/ui/button'
5
6
  import { useI18n } from '@renderer/i18n'
7
+ import { cn } from '@renderer/lib/utils'
6
8
  import { getPortalContainer } from '../../../dsh/portal'
9
+ import { DirectoryPickerDialog } from './DirectoryPickerDialog'
7
10
 
8
11
  const STORAGE_KEY = 'cortex.export.dir'
12
+ const FORMAT_KEY = 'cortex.export.format'
9
13
 
10
14
  function isAbsoluteLike(value: string): boolean {
11
15
  const trimmed = value.trim()
@@ -21,7 +25,7 @@ interface ExportReportDialogProps {
21
25
  count: number
22
26
  pending: boolean
23
27
  onOpenChange: (open: boolean) => void
24
- onConfirm: (outputDir: string | undefined) => void
28
+ onConfirm: (outputDir: string | undefined, format: ExportReportFormat) => void
25
29
  }
26
30
 
27
31
  /**
@@ -38,13 +42,49 @@ export function ExportReportDialog({
38
42
  }: ExportReportDialogProps) {
39
43
  const { t } = useI18n()
40
44
  const [directory, setDirectory] = useState('')
45
+ const [format, setFormat] = useState<ExportReportFormat>('md')
41
46
  const [invalid, setInvalid] = useState(false)
47
+ const [pickerOpen, setPickerOpen] = useState(false)
42
48
 
43
49
  useEffect(() => {
44
50
  if (!open) return
45
- const stored = window.localStorage.getItem(STORAGE_KEY) ?? ''
51
+ let alive = true
52
+ let stored = ''
53
+ try {
54
+ stored = window.localStorage.getItem(STORAGE_KEY) ?? ''
55
+ } catch {
56
+ stored = ''
57
+ }
58
+ const storedFormat = (() => {
59
+ try {
60
+ return window.localStorage.getItem(FORMAT_KEY)
61
+ } catch {
62
+ return null
63
+ }
64
+ })()
46
65
  setDirectory(stored)
66
+ setFormat(storedFormat === 'docx' || storedFormat === 'pdf' ? storedFormat : 'md')
47
67
  setInvalid(false)
68
+ // The Host remembers the last successful export directory; prefer it over
69
+ // any stale per-browser value so a fresh WebView still defaults correctly.
70
+ void fetch('/cortex-api/cortex/export/settings')
71
+ .then((response) => (response.ok ? response.json() : null))
72
+ .then((payload) => {
73
+ if (!alive) return
74
+ const hostDir = payload && typeof payload.outputDir === 'string' ? payload.outputDir.trim() : ''
75
+ if (hostDir.length > 0) {
76
+ setDirectory(hostDir)
77
+ try {
78
+ window.localStorage.setItem(STORAGE_KEY, hostDir)
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ }
83
+ })
84
+ .catch(() => {})
85
+ return () => {
86
+ alive = false
87
+ }
48
88
  }, [open])
49
89
 
50
90
  const confirm = (): void => {
@@ -53,68 +93,142 @@ export function ExportReportDialog({
53
93
  setInvalid(true)
54
94
  return
55
95
  }
56
- if (trimmed !== '') window.localStorage.setItem(STORAGE_KEY, trimmed)
57
- else window.localStorage.removeItem(STORAGE_KEY)
58
- onConfirm(trimmed === '' ? undefined : trimmed)
96
+ try {
97
+ if (trimmed !== '') window.localStorage.setItem(STORAGE_KEY, trimmed)
98
+ else window.localStorage.removeItem(STORAGE_KEY)
99
+ window.localStorage.setItem(FORMAT_KEY, format)
100
+ } catch {
101
+ /* localStorage may be unavailable; the Host remembers the directory */
102
+ }
103
+ onConfirm(trimmed === '' ? undefined : trimmed, format)
59
104
  }
60
105
 
61
- return (
62
- <DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
63
- <DialogPrimitive.Portal container={getPortalContainer()}>
64
- <DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
65
- <DialogPrimitive.Content
66
- data-testid="export-report-dialog"
67
- className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 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"
68
- >
69
- <DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
70
- {t('vuln.export.title')}
71
- </DialogPrimitive.Title>
72
- <DialogPrimitive.Description className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
73
- {t('vuln.export.description', { count })}
74
- </DialogPrimitive.Description>
106
+ const formats: Array<{ value: ExportReportFormat; label: string; testId: string }> = [
107
+ { value: 'md', label: t('vuln.export.formatMd'), testId: 'export-format-md' },
108
+ { value: 'docx', label: t('vuln.export.formatDocx'), testId: 'export-format-docx' },
109
+ { value: 'pdf', label: t('vuln.export.formatPdf'), testId: 'export-format-pdf' },
110
+ ]
75
111
 
76
- <label
77
- htmlFor="export-output-dir"
78
- className="mt-4 flex items-center gap-1.5 text-[11.5px] font-medium text-foreground"
79
- >
80
- <FolderOpen aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
81
- {t('vuln.export.dirLabel')}
82
- </label>
83
- <input
84
- id="export-output-dir"
85
- data-testid="export-output-dir"
86
- value={directory}
87
- spellCheck={false}
88
- disabled={pending}
89
- placeholder={t('vuln.export.dirPlaceholder')}
90
- onChange={(event) => {
91
- setDirectory(event.target.value)
92
- if (invalid) setInvalid(false)
112
+ return (
113
+ <>
114
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
115
+ <DialogPrimitive.Portal container={getPortalContainer()}>
116
+ <DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
117
+ <DialogPrimitive.Content
118
+ data-testid="export-report-dialog"
119
+ onInteractOutside={(event) => {
120
+ // The directory picker is a sibling Radix layer; keep this dialog
121
+ // open while the user browses folders.
122
+ if (pickerOpen) event.preventDefault()
93
123
  }}
94
- onKeyDown={(event) => {
95
- if (event.key === 'Enter') confirm()
124
+ onEscapeKeyDown={(event) => {
125
+ if (pickerOpen) event.preventDefault()
96
126
  }}
97
- className="mt-1.5 h-9 w-full rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
98
- />
99
- <p className={invalid ? 'mt-1.5 text-[10.5px] text-red-700' : 'mt-1.5 text-[10.5px] text-muted-foreground'}>
100
- {invalid ? t('vuln.export.dirInvalid') : t('vuln.export.dirHint')}
101
- </p>
127
+ className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 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"
128
+ >
129
+ <DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
130
+ {t('vuln.export.title')}
131
+ </DialogPrimitive.Title>
132
+ <DialogPrimitive.Description className="sr-only">
133
+ {t('vuln.export.title')}
134
+ </DialogPrimitive.Description>
135
+
136
+ <div className="mt-4">
137
+ <p className="text-[11.5px] font-medium text-foreground">{t('vuln.export.formatLabel')}</p>
138
+ <div
139
+ role="radiogroup"
140
+ aria-label={t('vuln.export.formatLabel')}
141
+ className="mt-1.5 inline-flex rounded-md border border-border bg-muted p-0.5"
142
+ >
143
+ {formats.map((item) => (
144
+ <button
145
+ key={item.value}
146
+ type="button"
147
+ role="radio"
148
+ aria-checked={format === item.value}
149
+ data-testid={item.testId}
150
+ disabled={pending}
151
+ onClick={() => setFormat(item.value)}
152
+ className={cn(
153
+ 'h-7 rounded px-2.5 text-[11.5px] transition-colors',
154
+ format === item.value
155
+ ? 'bg-background font-medium text-foreground shadow-sm'
156
+ : 'text-muted-foreground hover:text-foreground'
157
+ )}
158
+ >
159
+ {item.label}
160
+ </button>
161
+ ))}
162
+ </div>
163
+ </div>
164
+
165
+ <label
166
+ htmlFor="export-output-dir"
167
+ className="mt-4 flex items-center gap-1.5 text-[11.5px] font-medium text-foreground"
168
+ >
169
+ <FolderOpen aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
170
+ {t('vuln.export.dirLabel')}
171
+ </label>
172
+ <div className="mt-1.5 flex items-center gap-1.5">
173
+ <input
174
+ id="export-output-dir"
175
+ data-testid="export-output-dir"
176
+ value={directory}
177
+ spellCheck={false}
178
+ disabled={pending}
179
+ placeholder={t('vuln.export.dirPlaceholder')}
180
+ onChange={(event) => {
181
+ setDirectory(event.target.value)
182
+ if (invalid) setInvalid(false)
183
+ }}
184
+ onKeyDown={(event) => {
185
+ if (event.key === 'Enter') confirm()
186
+ }}
187
+ className="h-9 min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
188
+ />
189
+ <Button
190
+ variant="outline"
191
+ size="sm"
192
+ data-testid="export-browse-dir"
193
+ disabled={pending}
194
+ onClick={() => setPickerOpen(true)}
195
+ className="h-9 shrink-0 gap-1.5"
196
+ >
197
+ <FolderOpen aria-hidden="true" className="h-3.5 w-3.5" />
198
+ {t('vuln.export.browse')}
199
+ </Button>
200
+ </div>
201
+ {invalid ? (
202
+ <p className="mt-1.5 text-[10.5px] text-red-700">{t('vuln.export.dirInvalid')}</p>
203
+ ) : null}
204
+
205
+ <div className="mt-5 flex items-center justify-end gap-2">
206
+ <Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
207
+ {t('common.cancel')}
208
+ </Button>
209
+ <Button size="sm" onClick={confirm} disabled={pending} className="gap-1.5">
210
+ {pending ? (
211
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
212
+ ) : (
213
+ <Download aria-hidden="true" className="h-3.5 w-3.5" />
214
+ )}
215
+ {pending ? t('vuln.detail.exporting') : t('vuln.export.confirm')}
216
+ </Button>
217
+ </div>
218
+ </DialogPrimitive.Content>
219
+ </DialogPrimitive.Portal>
220
+ </DialogPrimitive.Root>
102
221
 
103
- <div className="mt-5 flex items-center justify-end gap-2">
104
- <Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
105
- {t('common.cancel')}
106
- </Button>
107
- <Button size="sm" onClick={confirm} disabled={pending} className="gap-1.5">
108
- {pending ? (
109
- <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
110
- ) : (
111
- <Download aria-hidden="true" className="h-3.5 w-3.5" />
112
- )}
113
- {pending ? t('vuln.detail.exporting') : t('vuln.export.confirm')}
114
- </Button>
115
- </div>
116
- </DialogPrimitive.Content>
117
- </DialogPrimitive.Portal>
118
- </DialogPrimitive.Root>
222
+ <DirectoryPickerDialog
223
+ open={pickerOpen}
224
+ initialPath={directory}
225
+ onOpenChange={setPickerOpen}
226
+ onSelect={(path) => {
227
+ setDirectory(path)
228
+ setInvalid(false)
229
+ setPickerOpen(false)
230
+ }}
231
+ />
232
+ </>
119
233
  )
120
234
  }