@lievclouds/dsh-cortex 0.4.4 → 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.4",
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
@@ -123,7 +124,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
123
124
  const [raw, setRaw] = useState('')
124
125
  const [step, setStep] = useState(0)
125
126
  const [sending, setSending] = useState(false)
126
- const [bodyView, setBodyView] = useState<'raw' | 'html'>('raw')
127
+ const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
127
128
  const [error, setError] = useState<string | null>(null)
128
129
  const [result, setResult] = useState<RepeaterSendResult | null>(null)
129
130
 
@@ -156,6 +157,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
156
157
  const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
157
158
  const responseIsRedirect = result !== null && isRedirectResponse(result)
158
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
+ )
159
167
  const htmlPreview = useMemo(
160
168
  () => (result === null || vuln === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
161
169
  [result, vuln]
@@ -306,7 +314,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
306
314
  {t('retest.repeater.followRedirect')}
307
315
  </Button>
308
316
  ) : null}
309
- {responseIsHtml ? (
317
+ {formattedJson !== null || responseIsHtml ? (
310
318
  <div className="ml-auto flex items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
311
319
  <button
312
320
  type="button"
@@ -320,18 +328,34 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
320
328
  >
321
329
  {t('retest.repeater.viewRaw')}
322
330
  </button>
323
- <button
324
- type="button"
325
- data-testid="retest-repeater-view-html"
326
- onClick={() => setBodyView('html')}
327
- className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
328
- bodyView === 'html'
329
- ? 'bg-background font-medium text-foreground shadow-sm'
330
- : 'text-muted-foreground hover:text-foreground'
331
- }`}
332
- >
333
- {t('retest.repeater.viewHtml')}
334
- </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}
335
359
  </div>
336
360
  ) : null}
337
361
  </>
@@ -356,6 +380,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
356
380
  srcDoc={htmlPreview}
357
381
  className="h-[min(60vh,520px)] min-h-[320px] w-full rounded-md border border-border bg-white"
358
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>
359
390
  ) : (
360
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%)]">
361
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
  }
@@ -254,16 +254,17 @@ export function VulnDetail({
254
254
  }
255
255
  }
256
256
 
257
- const handleExport = async (outputDir?: string) => {
257
+ const handleExport = async (outputDir?: string, format?: 'md' | 'docx' | 'pdf') => {
258
258
  if (!detail || loading) return
259
259
  const api = getApi()
260
260
  if (!api) {
261
261
  toast.error(apiUnavailableMessage(t))
262
+ setExportOpen(false)
262
263
  return
263
264
  }
264
265
  setExporting(true)
265
266
  try {
266
- const result = await api.exportVulnerabilities([detail.id], outputDir)
267
+ const result = await api.exportVulnerabilities([detail.id], { outputDir, format })
267
268
  if (!result.cancelled) {
268
269
  const savedPath = result.savedPaths?.[0]
269
270
  toast.success(
@@ -287,7 +288,18 @@ export function VulnDetail({
287
288
  if (!next) onClose()
288
289
  }}
289
290
  >
290
- <SheetContent data-testid="vuln-sheet">
291
+ <SheetContent
292
+ data-testid="vuln-sheet"
293
+ onInteractOutside={(event) => {
294
+ // Nested dialogs (export/build path, delete confirm) are portaled
295
+ // outside this sheet in the DOM; without this the sheet treats a
296
+ // click inside them as an outside click and closes itself.
297
+ if (exportOpen || confirmOpen) event.preventDefault()
298
+ }}
299
+ onEscapeKeyDown={(event) => {
300
+ if (exportOpen || confirmOpen) event.preventDefault()
301
+ }}
302
+ >
291
303
  <div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-6">
292
304
  <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
293
305
  {t('vuln.detail.eyebrow')}
@@ -629,7 +641,7 @@ export function VulnDetail({
629
641
  count={1}
630
642
  pending={exporting}
631
643
  onOpenChange={setExportOpen}
632
- onConfirm={(outputDir) => void handleExport(outputDir)}
644
+ onConfirm={(outputDir, format) => void handleExport(outputDir, format)}
633
645
  />
634
646
  </>
635
647
  )