dsh-plugin-capabilities 0.1.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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +863 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +1514 -0
- package/lib/index.js.map +7 -0
- package/package.json +64 -0
- package/src/agents.test.ts +105 -0
- package/src/agents.ts +135 -0
- package/src/client/McpTab.tsx +411 -0
- package/src/client/SkillsTab.tsx +301 -0
- package/src/client/css.ts +47 -0
- package/src/client/desktop-bridge.d.ts +12 -0
- package/src/client/index.ts +108 -0
- package/src/client/locales.ts +131 -0
- package/src/client/primitives.d.ts +57 -0
- package/src/http.ts +42 -0
- package/src/index.ts +56 -0
- package/src/mcp.test.ts +85 -0
- package/src/mcp.ts +170 -0
- package/src/profile.ts +17 -0
- package/src/routes.ts +285 -0
- package/src/skills.test.ts +73 -0
- package/src/skills.ts +81 -0
- package/src/smoke.test.ts +150 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/** Settings → Plugins “MCP” tab: manage the profile's mcp-client rows.
|
|
2
|
+
* Mutations rewrite the profile patch and need a dsh restart — the banner
|
|
3
|
+
* hands the restart to the desktop shell when one is present. */
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import type { ReactElement } from 'react'
|
|
7
|
+
import { Button, IconApiOutline14, IconRefreshOutline14, Modal, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
8
|
+
import { CSS } from './css.ts'
|
|
9
|
+
import type { ImportedServerView, Translate } from './index.ts'
|
|
10
|
+
|
|
11
|
+
export interface McpRow {
|
|
12
|
+
id: string
|
|
13
|
+
serverName: string
|
|
14
|
+
transport: 'stdio' | 'streamable-http'
|
|
15
|
+
disabled: boolean
|
|
16
|
+
command?: string
|
|
17
|
+
args?: string[]
|
|
18
|
+
env?: Record<string, string>
|
|
19
|
+
cwd?: string
|
|
20
|
+
url?: string
|
|
21
|
+
headers?: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface McpInjected {
|
|
25
|
+
list(): Promise<{ servers: McpRow[] }>
|
|
26
|
+
save(input: Record<string, unknown>): Promise<{ ok: boolean; id: string }>
|
|
27
|
+
toggle(id: string, disabled: boolean): Promise<{ ok: boolean }>
|
|
28
|
+
remove(id: string): Promise<{ ok: boolean }>
|
|
29
|
+
scanImport(): Promise<{ servers: ImportedServerView[]; existing: string[] }>
|
|
30
|
+
applyImport(items: Array<{ agent: string; name: string }>): Promise<{ ok: boolean; results: Array<{ name: string; ok: boolean; error?: string }> }>
|
|
31
|
+
restart(): void
|
|
32
|
+
desktop: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Editor dialog state; null when closed. Textareas hold line-based values. */
|
|
36
|
+
interface EditorState {
|
|
37
|
+
id: string
|
|
38
|
+
serverName: string
|
|
39
|
+
transport: 'stdio' | 'streamable-http'
|
|
40
|
+
command: string
|
|
41
|
+
args: string
|
|
42
|
+
env: string
|
|
43
|
+
url: string
|
|
44
|
+
headers: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** KEY=VALUE / KEY: VALUE lines to a map. */
|
|
48
|
+
function parsePairs(text: string, separator: ':' | '='): Record<string, string> {
|
|
49
|
+
const map: Record<string, string> = {}
|
|
50
|
+
for (const line of text.split(/\r?\n/)) {
|
|
51
|
+
const trimmed = line.trim()
|
|
52
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue
|
|
53
|
+
const at = trimmed.indexOf(separator)
|
|
54
|
+
if (at <= 0) continue
|
|
55
|
+
map[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim()
|
|
56
|
+
}
|
|
57
|
+
return map
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mapToPairs(map: Record<string, string> | undefined, separator: string): string {
|
|
61
|
+
if (map === undefined) return ''
|
|
62
|
+
return Object.entries(map).map(([key, value]) => `${key}${separator}${value.includes('\n') ? JSON.stringify(value) : value}`).join('\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function McpTab(props: { t: Translate; injected: McpInjected }): ReactElement {
|
|
66
|
+
const { t, injected } = props
|
|
67
|
+
const [servers, setServers] = useState<McpRow[] | null>(null)
|
|
68
|
+
const [editor, setEditor] = useState<EditorState | null>(null)
|
|
69
|
+
const [confirmId, setConfirmId] = useState<string | null>(null)
|
|
70
|
+
const [importOpen, setImportOpen] = useState(false)
|
|
71
|
+
const [importItems, setImportItems] = useState<Array<{ server: ImportedServerView; existing: boolean; checked: boolean }> | null>(null)
|
|
72
|
+
const [busy, setBusy] = useState(false)
|
|
73
|
+
const [pending, setPending] = useState(false)
|
|
74
|
+
const [outcome, setOutcome] = useState<{ ok: boolean; text: string } | null>(null)
|
|
75
|
+
const [formError, setFormError] = useState<string | null>(null)
|
|
76
|
+
const [reload, setReload] = useState(0)
|
|
77
|
+
|
|
78
|
+
const openImport = async (): Promise<void> => {
|
|
79
|
+
setImportOpen(true)
|
|
80
|
+
setImportItems(null)
|
|
81
|
+
try {
|
|
82
|
+
const body = await injected.scanImport()
|
|
83
|
+
const existing = new Set(body.existing)
|
|
84
|
+
setImportItems(body.servers.map(server => ({
|
|
85
|
+
server,
|
|
86
|
+
existing: existing.has(server.name),
|
|
87
|
+
checked: !existing.has(server.name),
|
|
88
|
+
})))
|
|
89
|
+
} catch (error) {
|
|
90
|
+
setImportItems([])
|
|
91
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const doImport = async (): Promise<void> => {
|
|
96
|
+
if (importItems === null) return
|
|
97
|
+
const items = importItems.filter(item => item.checked && !item.existing).map(item => ({ agent: item.server.agent, name: item.server.name }))
|
|
98
|
+
setBusy(true)
|
|
99
|
+
try {
|
|
100
|
+
const body = await injected.applyImport(items)
|
|
101
|
+
const failed = body.results.filter(item => !item.ok)
|
|
102
|
+
setOutcome(failed.length === 0
|
|
103
|
+
? { ok: true, text: t('restartNeeded') }
|
|
104
|
+
: { ok: false, text: `${t('failed')}: ${failed.map(item => `${item.name} (${item.error})`).join(', ')}` })
|
|
105
|
+
setImportOpen(false)
|
|
106
|
+
setPending(true)
|
|
107
|
+
setReload((value) => value + 1)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
110
|
+
} finally {
|
|
111
|
+
setBusy(false)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reloadList = (showPending: boolean): void => {
|
|
116
|
+
setReload((value) => value + 1)
|
|
117
|
+
if (showPending) setPending(true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
let current = true
|
|
122
|
+
void injected.list().then(
|
|
123
|
+
(body) => { if (current) setServers(body.servers) },
|
|
124
|
+
(error: Error) => { if (current) { setServers([]); setOutcome({ ok: false, text: `${t('failed')}: ${String(error.message ?? error)}` }) } },
|
|
125
|
+
)
|
|
126
|
+
return () => { current = false }
|
|
127
|
+
}, [injected, reload, t])
|
|
128
|
+
|
|
129
|
+
const openCreate = (): void => {
|
|
130
|
+
setFormError(null)
|
|
131
|
+
setEditor({ id: '', serverName: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const openEdit = (row: McpRow): void => {
|
|
135
|
+
setFormError(null)
|
|
136
|
+
setEditor({
|
|
137
|
+
id: row.id,
|
|
138
|
+
serverName: row.serverName,
|
|
139
|
+
transport: row.transport,
|
|
140
|
+
command: row.command ?? '',
|
|
141
|
+
args: (row.args ?? []).join('\n'),
|
|
142
|
+
env: mapToPairs(row.env, '='),
|
|
143
|
+
url: row.url ?? '',
|
|
144
|
+
headers: mapToPairs(row.headers, ':'),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const doSave = async (): Promise<void> => {
|
|
149
|
+
if (editor === null) return
|
|
150
|
+
setBusy(true)
|
|
151
|
+
setFormError(null)
|
|
152
|
+
try {
|
|
153
|
+
await injected.save({
|
|
154
|
+
id: editor.id,
|
|
155
|
+
serverName: editor.serverName.trim(),
|
|
156
|
+
transport: editor.transport,
|
|
157
|
+
...(editor.transport === 'stdio'
|
|
158
|
+
? {
|
|
159
|
+
command: editor.command.trim(),
|
|
160
|
+
args: editor.args.split(/\r?\n/).map(line => line.trim()).filter(line => line !== ''),
|
|
161
|
+
env: parsePairs(editor.env, '='),
|
|
162
|
+
}
|
|
163
|
+
: {
|
|
164
|
+
url: editor.url.trim(),
|
|
165
|
+
headers: parsePairs(editor.headers, ':'),
|
|
166
|
+
}),
|
|
167
|
+
})
|
|
168
|
+
setEditor(null)
|
|
169
|
+
setOutcome({ ok: true, text: t('restartNeeded') })
|
|
170
|
+
reloadList(true)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
setFormError(String(error instanceof Error ? error.message : error))
|
|
173
|
+
} finally {
|
|
174
|
+
setBusy(false)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const doToggle = async (row: McpRow): Promise<void> => {
|
|
179
|
+
setBusy(true)
|
|
180
|
+
try {
|
|
181
|
+
await injected.toggle(row.id, !row.disabled)
|
|
182
|
+
setOutcome({ ok: true, text: t('restartNeeded') })
|
|
183
|
+
reloadList(true)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
186
|
+
} finally {
|
|
187
|
+
setBusy(false)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const doRemove = async (): Promise<void> => {
|
|
192
|
+
if (confirmId === null) return
|
|
193
|
+
setBusy(true)
|
|
194
|
+
try {
|
|
195
|
+
await injected.remove(confirmId)
|
|
196
|
+
setOutcome({ ok: true, text: t('restartNeeded') })
|
|
197
|
+
reloadList(true)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
setOutcome({ ok: false, text: `${t('failed')}: ${String(error instanceof Error ? error.message : error)}` })
|
|
200
|
+
} finally {
|
|
201
|
+
setBusy(false)
|
|
202
|
+
setConfirmId(null)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const restartBanner = (
|
|
207
|
+
<div className="dpc-banner" data-kind="info" role="status">
|
|
208
|
+
<StateDot state="ongoing" size={10} />
|
|
209
|
+
<div className="dpc-bannerBody">
|
|
210
|
+
<span>{t('restartNeeded')}</span>
|
|
211
|
+
<span className="dpc-bannerHint">
|
|
212
|
+
{injected.desktop
|
|
213
|
+
? <>{t('restartDesktopHint')}{' '}<Button variant="outline" size="sm" onClick={injected.restart}>{t('restartNow')}</Button></>
|
|
214
|
+
: t('restartOtherHint')}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="dpc-section">
|
|
222
|
+
<style>{CSS}</style>
|
|
223
|
+
|
|
224
|
+
<div className="dpc-head">
|
|
225
|
+
<IconApiOutline14 aria-hidden="true" />
|
|
226
|
+
<h3>{t('mcpTitle')}</h3>
|
|
227
|
+
<span className="dpc-spacer" />
|
|
228
|
+
<Button variant="ghost" size="sm" onClick={() => void openImport()}>{t('importServers')}</Button>
|
|
229
|
+
<Button variant="primary" size="sm" onClick={openCreate}>{t('addServer')}</Button>
|
|
230
|
+
</div>
|
|
231
|
+
<p className="dpc-intro">{t('mcpIntro')}</p>
|
|
232
|
+
|
|
233
|
+
{outcome !== null && (
|
|
234
|
+
<div className="dpc-banner" data-kind={outcome.ok ? 'ok' : 'error'} role="status">
|
|
235
|
+
<StateDot state={outcome.ok ? 'done' : 'error'} size={10} />
|
|
236
|
+
<div className="dpc-bannerBody"><span>{outcome.text}</span></div>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{pending && restartBanner}
|
|
240
|
+
|
|
241
|
+
<div className="dpc-listHead">
|
|
242
|
+
<h3>{t('mcpTab')}</h3>
|
|
243
|
+
{servers !== null && <span className="dpc-count">{servers.length}</span>}
|
|
244
|
+
<span className="dpc-spacer" />
|
|
245
|
+
<button type="button" className="dpc-refresh" aria-label={t('view')} title={t('view')} disabled={busy} onClick={() => setReload((value) => value + 1)}>
|
|
246
|
+
<IconRefreshOutline14 size={14} aria-hidden="true" />
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{servers === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
251
|
+
{servers !== null && servers.length === 0 && <p className="dpc-empty">{t('emptyMcp')}</p>}
|
|
252
|
+
{servers !== null && servers.length > 0 && (
|
|
253
|
+
<ul className="dpc-cards">
|
|
254
|
+
{servers.map((row) => (
|
|
255
|
+
<li className="dpc-card" key={row.id}>
|
|
256
|
+
<div className="dpc-cardTop">
|
|
257
|
+
<strong className="dpc-cardTitle" title={row.id}>{row.serverName}</strong>
|
|
258
|
+
<span className="dpc-tag">{row.transport}</span>
|
|
259
|
+
<span className="dpc-tag" data-kind={row.disabled ? 'off' : undefined}>{row.disabled ? t('disabled') : t('enabled')}</span>
|
|
260
|
+
</div>
|
|
261
|
+
<p className="dpc-cardDesc">
|
|
262
|
+
{row.transport === 'stdio' ? `${row.command ?? ''} ${(row.args ?? []).join(' ')}` : row.url ?? ''}
|
|
263
|
+
</p>
|
|
264
|
+
<div className="dpc-cardRow">
|
|
265
|
+
<span className="dpc-spacer" />
|
|
266
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void doToggle(row)}>{t('toggle')}</Button>
|
|
267
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => openEdit(row)}>{t('edit')}</Button>
|
|
268
|
+
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirmId(row.id)}>{t('delete')}</Button>
|
|
269
|
+
</div>
|
|
270
|
+
</li>
|
|
271
|
+
))}
|
|
272
|
+
</ul>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
<Modal
|
|
276
|
+
open={editor !== null}
|
|
277
|
+
onClose={() => setEditor(null)}
|
|
278
|
+
title={editor !== null && editor.id !== '' ? t('editServer') : t('addServer')}
|
|
279
|
+
>
|
|
280
|
+
{editor !== null && (
|
|
281
|
+
<div className="dpc-form">
|
|
282
|
+
<label className="dpc-label">
|
|
283
|
+
<span>{t('serverName')}</span>
|
|
284
|
+
<input
|
|
285
|
+
className="dpc-input"
|
|
286
|
+
value={editor.serverName}
|
|
287
|
+
disabled={editor.id !== ''}
|
|
288
|
+
onChange={(event) => setEditor({ ...editor, serverName: event.target.value })}
|
|
289
|
+
/>
|
|
290
|
+
</label>
|
|
291
|
+
<label className="dpc-label">
|
|
292
|
+
<span>{t('transport')}</span>
|
|
293
|
+
<select
|
|
294
|
+
className="dpc-select"
|
|
295
|
+
value={editor.transport}
|
|
296
|
+
disabled={editor.id !== ''}
|
|
297
|
+
onChange={(event) => setEditor({ ...editor, transport: event.target.value as EditorState['transport'] })}
|
|
298
|
+
>
|
|
299
|
+
<option value="stdio">{t('transportStdio')}</option>
|
|
300
|
+
<option value="streamable-http">{t('transportHttp')}</option>
|
|
301
|
+
</select>
|
|
302
|
+
</label>
|
|
303
|
+
{editor.transport === 'stdio' ? (
|
|
304
|
+
<>
|
|
305
|
+
<label className="dpc-label">
|
|
306
|
+
<span>{t('command')}</span>
|
|
307
|
+
<input className="dpc-input" value={editor.command} onChange={(event) => setEditor({ ...editor, command: event.target.value })} />
|
|
308
|
+
</label>
|
|
309
|
+
<label className="dpc-label">
|
|
310
|
+
<span>{t('args')}</span>
|
|
311
|
+
<textarea className="dpc-textarea" data-short="true" value={editor.args} onChange={(event) => setEditor({ ...editor, args: event.target.value })} />
|
|
312
|
+
</label>
|
|
313
|
+
<label className="dpc-label">
|
|
314
|
+
<span>{t('envPairs')}</span>
|
|
315
|
+
<textarea className="dpc-textarea" data-short="true" value={editor.env} onChange={(event) => setEditor({ ...editor, env: event.target.value })} />
|
|
316
|
+
</label>
|
|
317
|
+
</>
|
|
318
|
+
) : (
|
|
319
|
+
<>
|
|
320
|
+
<label className="dpc-label">
|
|
321
|
+
<span>{t('url')}</span>
|
|
322
|
+
<input className="dpc-input" value={editor.url} onChange={(event) => setEditor({ ...editor, url: event.target.value })} />
|
|
323
|
+
</label>
|
|
324
|
+
<label className="dpc-label">
|
|
325
|
+
<span>{t('headersPairs')}</span>
|
|
326
|
+
<textarea className="dpc-textarea" data-short="true" value={editor.headers} onChange={(event) => setEditor({ ...editor, headers: event.target.value })} />
|
|
327
|
+
</label>
|
|
328
|
+
</>
|
|
329
|
+
)}
|
|
330
|
+
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
331
|
+
<div className="dpc-cardRow">
|
|
332
|
+
<span className="dpc-spacer" />
|
|
333
|
+
<Button variant="ghost" onClick={() => setEditor(null)}>{t('cancel')}</Button>
|
|
334
|
+
<Button variant="primary" disabled={busy} onClick={() => void doSave()}>{t('save')}</Button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
</Modal>
|
|
339
|
+
|
|
340
|
+
<Modal
|
|
341
|
+
open={confirmId !== null}
|
|
342
|
+
onClose={() => setConfirmId(null)}
|
|
343
|
+
title={t('confirmRemove')}
|
|
344
|
+
description={confirmId ?? undefined}
|
|
345
|
+
footer={
|
|
346
|
+
<>
|
|
347
|
+
<Button variant="ghost" onClick={() => setConfirmId(null)}>{t('cancel')}</Button>
|
|
348
|
+
<Button variant="primary" disabled={busy} onClick={() => void doRemove()}>{t('delete')}</Button>
|
|
349
|
+
</>
|
|
350
|
+
}
|
|
351
|
+
>
|
|
352
|
+
<p>{t('removeWarn')}</p>
|
|
353
|
+
</Modal>
|
|
354
|
+
|
|
355
|
+
<Modal
|
|
356
|
+
open={importOpen}
|
|
357
|
+
onClose={() => setImportOpen(false)}
|
|
358
|
+
title={t('importServers')}
|
|
359
|
+
>
|
|
360
|
+
<div className="dpc-form">
|
|
361
|
+
<p className="dpc-intro" style={{ margin: 0 }}>{t('importIntro')}</p>
|
|
362
|
+
{importItems === null && <p className="dpc-empty">{t('loading')}</p>}
|
|
363
|
+
{importItems !== null && importItems.length === 0 && <p className="dpc-empty">{t('importEmpty')}</p>}
|
|
364
|
+
{importItems !== null && importItems.length > 0 && (
|
|
365
|
+
<ul className="dpc-cards" style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}>
|
|
366
|
+
{importItems.map((item, index) => (
|
|
367
|
+
<li className="dpc-card" key={`${item.server.agent}/${item.server.name}`} style={{ opacity: item.existing ? 0.55 : 1 }}>
|
|
368
|
+
<div className="dpc-cardTop">
|
|
369
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0, cursor: item.existing ? 'default' : 'pointer' }}>
|
|
370
|
+
<input
|
|
371
|
+
type="checkbox"
|
|
372
|
+
checked={item.checked}
|
|
373
|
+
disabled={item.existing}
|
|
374
|
+
onChange={(event) => {
|
|
375
|
+
const next = importItems.slice()
|
|
376
|
+
next[index] = { ...item, checked: event.target.checked }
|
|
377
|
+
setImportItems(next)
|
|
378
|
+
}}
|
|
379
|
+
/>
|
|
380
|
+
<strong className="dpc-cardTitle" title={item.server.name}>{item.server.name}</strong>
|
|
381
|
+
</label>
|
|
382
|
+
<span className="dpc-tag" data-kind="source">{item.server.agent === 'claude-code' ? 'Claude Code' : 'Codex'}</span>
|
|
383
|
+
<span className="dpc-tag">{item.server.transport}</span>
|
|
384
|
+
{item.existing && <span className="dpc-tag">{t('importExisting')}</span>}
|
|
385
|
+
</div>
|
|
386
|
+
<p className="dpc-cardDesc">
|
|
387
|
+
{item.server.transport === 'stdio'
|
|
388
|
+
? `${item.server.command ?? ''} ${(item.server.args ?? []).join(' ')}`
|
|
389
|
+
: item.server.url ?? ''}
|
|
390
|
+
</p>
|
|
391
|
+
</li>
|
|
392
|
+
))}
|
|
393
|
+
</ul>
|
|
394
|
+
)}
|
|
395
|
+
{formError !== null && <p className="dpc-formError">{formError}</p>}
|
|
396
|
+
<div className="dpc-cardRow">
|
|
397
|
+
<span className="dpc-spacer" />
|
|
398
|
+
<Button variant="ghost" onClick={() => setImportOpen(false)}>{t('cancel')}</Button>
|
|
399
|
+
<Button
|
|
400
|
+
variant="primary"
|
|
401
|
+
disabled={busy || importItems === null || !importItems.some(item => item.checked && !item.existing)}
|
|
402
|
+
onClick={() => void doImport()}
|
|
403
|
+
>
|
|
404
|
+
{t('importSelected')}
|
|
405
|
+
</Button>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</Modal>
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|