import { X } from 'lucide-react'
import { type KeyboardEvent, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface ExecAllowlistEditorProps {
value: string[]
onChange: (next: string[]) => void
disabled?: boolean
className?: string
/**
* If provided, every committed value is validated before being added.
* Returns an i18n key for the error message, or null if valid.
* Used by CORS origin editor to validate URLs.
*/
validate?: (value: string) => string | null
/**
* Optional list of suggested values with display labels and optional group.
* When provided, the input shows a suggestion popover.
* Used by `allowed_tools` to show the tool catalog.
*/
suggestions?: { value: string; label: string; group?: string }[]
}
/**
* Multi-line tag input for `exec.allowed_commands`, `cors_origins`,
* `allowed_tools`, etc. Enter or comma adds a new tag; backspace on
* empty input removes the last tag.
*
* - `suggestions` prop → suggestion popover (tool catalog)
* - `validate` prop → inline error on invalid input (CORS origins)
*/
export function ExecAllowlistEditor({
value,
onChange,
disabled,
className,
validate,
suggestions,
}: ExecAllowlistEditorProps) {
const { t } = useTranslation()
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
const [showSuggestions, setShowSuggestions] = useState(false)
const filteredSuggestions = suggestions
? suggestions.filter(
(s) => !value.includes(s.value) && s.value.toLowerCase().includes(draft.toLowerCase()),
)
: []
const commit = (raw: string) => {
const trimmed = raw.trim()
if (!trimmed) return
// Validate if prop provided.
if (validate) {
const err = validate(trimmed)
if (err) {
setError(err)
return
}
}
setError(null)
// De-duplicate.
if (value.includes(trimmed)) {
setDraft('')
return
}
onChange([...value, trimmed])
setDraft('')
setShowSuggestions(false)
}
const remove = (idx: number) => {
onChange(value.filter((_, i) => i !== idx))
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
commit(draft)
} else if (e.key === 'Backspace' && draft === '' && value.length > 0) {
e.preventDefault()
remove(value.length - 1)
} else if (e.key === 'ArrowDown' && filteredSuggestions.length > 0) {
e.preventDefault()
setShowSuggestions(true)
} else if (e.key === 'Escape') {
setShowSuggestions(false)
}
}
const handleSelectSuggestion = (suggestion: string) => {
setDraft('')
if (!value.includes(suggestion)) {
onChange([...value, suggestion])
}
setShowSuggestions(false)
setError(null)
}
// Group suggestions by category when available.
const groupedSuggestions = suggestions
? filteredSuggestions.reduce<Record<string, typeof filteredSuggestions>>((acc, s) => {
const group = s.group ?? t('common.other')
if (!acc[group]) acc[group] = []
acc[group].push(s)
return acc
}, {})
: {}
return (
<div className={cn('space-y-2', className)}>
<div
className={cn(
'flex flex-wrap gap-1.5 rounded-md border p-2 min-h-[2.5rem]',
error ? 'border-destructive bg-destructive/5' : 'bg-muted/30',
)}
>
{value.map((cmd, i) => (
<span
key={`${cmd}-${i}`}
className="inline-flex items-center gap-1 rounded-md bg-background border px-2 py-1 text-xs font-mono"
>
{cmd}
{!disabled && (
<button
type="button"
aria-label={t('common.delete')}
onClick={() => remove(i)}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<div className="relative flex-1 min-w-[120px]">
<Input
value={draft}
onChange={(e) => {
setDraft(e.target.value)
setError(null)
if (suggestions && e.target.value) setShowSuggestions(true)
else setShowSuggestions(false)
}}
onFocus={() => {
if (suggestions && draft) setShowSuggestions(true)
}}
onBlur={() => {
// Delay hiding so click on suggestion registers.
setTimeout(() => setShowSuggestions(false), 200)
}}
onKeyDown={handleKeyDown}
placeholder={
value.length === 0 && !suggestions ? t('settings.allowedCommandsPlaceholder') : ''
}
disabled={disabled}
className="h-7 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1"
/>
{/* Suggestions popover */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute left-0 top-full z-50 mt-1 w-64 rounded-md border bg-popover p-1 shadow-md">
{Object.entries(groupedSuggestions).map(([group, items]) => (
<div key={group}>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t(`categories.${group}`, group)}
</div>
{items.map((s) => (
<button
key={s.value}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onMouseDown={(e) => {
e.preventDefault()
handleSelectSuggestion(s.value)
}}
>
<span className="font-mono text-xs">{s.value}</span>
<span className="text-xs text-muted-foreground ml-auto">{s.label}</span>
</button>
))}
</div>
))}
</div>
)}
</div>
</div>
{/* Inline error */}
{error && <p className="text-xs text-destructive">{t(error)}</p>}
{value.length > 0 && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange([])}
className="text-xs text-muted-foreground"
>
{t('settings.clearAll')}
</Button>
)}
</div>
)
}