import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { ExecAllowlistEditor } from './exec-allowlist-editor'
import type { SettingsFieldDef } from './field-defs'
interface FieldRowProps {
/** Section key, e.g. `exec` or `memory`. */
sectionKey: string
/** The field definition. */
field: SettingsFieldDef
/** Current form value. */
value: string | boolean | string[] | number | undefined
/** Change handler. */
onChange: (val: string | boolean | string[]) => void
/**
* Whether this row has a pending (unsaved) change. Renders a 2px
* primary accent on the left edge of the row when true.
*/
modified?: boolean
/**
* Manual disable override. When true, the field is disabled
* regardless of `dependsOn` evaluation. Used by sections with
* their own form state (e.g. RoutingSection, ProviderOptions).
*/
disabled?: boolean
/**
* Section-level form values. When `field.dependsOn` is set, this
* is used to evaluate whether the field should be disabled based
* on the parent field's value.
*/
sectionValues?: Record<string, unknown>
/**
* Optional validate function for `tags` type fields.
* Called before adding a new tag; return an i18n key string on error.
*/
validate?: (value: string) => string | null
/**
* Optional suggestion list for `tags` type fields.
* When provided, the input shows a suggestion popover.
*/
suggestions?: { value: string; label: string; group?: string }[]
}
/**
* Single labelled form row. Renders the appropriate control for
* `field.type`. Restart-required info is shown only at save time (DiffPreview),
* not as a per-field badge.
*
* Layout is responsive:
* - < 768px: stacked (label/description/badge above, control full-width below)
* - >= 768px: 2-column (label block left, control right at 320px)
*/
export function FieldRow({
sectionKey,
field,
value,
onChange,
modified,
disabled: disabledProp,
sectionValues,
validate,
suggestions,
}: FieldRowProps) {
const { t } = useTranslation()
const id = `${sectionKey}-${field.key.replace(/\./g, '-')}`
// Evaluate dependsOn: if the parent field's value doesn't match,
// the field is automatically disabled.
const dependsOnDisabled =
field.dependsOn && sectionValues
? sectionValues[field.dependsOn.field] !== field.dependsOn.value
: false
const isDisabled = disabledProp || dependsOnDisabled
return (
<div
className={cn(
// Responsive field row (spec §5):
// < md : stacked (label above, control full-width below)
// md–lg : 2-col fluid ratio 40/60 (control scales with width)
// lg–xl : label fluid + control fixed 320px
// ≥ xl : label fluid + control fixed 360px
'group/field relative flex flex-col gap-3',
'md:grid md:grid-cols-[2fr_3fr] md:items-start md:gap-x-6',
'lg:grid-cols-[minmax(0,1fr)_320px] lg:gap-x-8',
'xl:grid-cols-[minmax(0,1fr)_360px]',
'rounded-lg px-3 -mx-3 py-3 transition-colors',
'hover:bg-muted/30',
modified && 'bg-modified-row-bg hover:bg-modified-row-bg',
)}
data-modified={modified ? 'true' : undefined}
>
{/* Modified accent bar (left edge) */}
{modified && (
<span
aria-hidden
className="absolute left-0 top-2 bottom-2 w-0.5 rounded-full bg-modified-accent"
/>
)}
{/* Label block */}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<label htmlFor={id} className="text-sm font-medium text-foreground">
{t(field.labelKey)}
</label>
</div>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{t(field.descriptionKey)}
</p>
</div>
{/* Control */}
<div className="w-full md:w-auto">
<FieldControl
id={id}
field={field}
value={value}
onChange={onChange}
disabled={isDisabled}
validate={validate}
suggestions={suggestions}
/>
</div>
</div>
)
}
function FieldControl({
id,
field,
value,
onChange,
disabled,
validate,
suggestions,
}: {
id: string
field: SettingsFieldDef
value: unknown
onChange: (v: string | boolean | string[]) => void
disabled: boolean
validate?: (value: string) => string | null
suggestions?: { value: string; label: string; group?: string }[]
}) {
const { t } = useTranslation()
switch (field.type) {
case 'toggle': {
return (
<div className="flex items-center justify-end gap-2 md:justify-start">
<span
className={`text-xs tabular-nums ${disabled ? 'text-muted-foreground/50' : 'text-muted-foreground'}`}
>
{value ? t('common.on') : t('common.off')}
</span>
<Switch
id={id}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
disabled={disabled}
/>
</div>
)
}
case 'select': {
return (
<Select
value={String(value ?? '')}
onValueChange={(v) => onChange(v)}
placeholder={t(field.labelKey)}
options={
Array.isArray(field.options)
? field.options.map((opt) => ({
label: t(opt.labelKey),
value: opt.value,
}))
: []
}
className="w-full"
disabled={disabled}
/>
)
}
case 'number': {
return (
<Input
id={id}
type="number"
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-mono"
disabled={disabled}
/>
)
}
case 'password': {
return (
<Input
id={id}
type="password"
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-mono"
disabled={disabled}
/>
)
}
case 'multiline': {
return (
<Textarea
id={id}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-mono text-xs"
rows={3}
disabled={disabled}
/>
)
}
case 'csv': {
// Comma-separated list. Convert on every change.
const stringified = Array.isArray(value) ? value.join(', ') : String(value ?? '')
return (
<Input
id={id}
type="text"
value={stringified}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-mono text-xs"
disabled={disabled}
/>
)
}
case 'numbers': {
// Multi-line number list (one per line). Stored as string, parsed on save.
const stringified = Array.isArray(value) ? value.join('\n') : String(value ?? '')
return (
<Textarea
id={id}
value={stringified}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-mono text-xs"
rows={3}
disabled={disabled}
/>
)
}
case 'tags': {
// Support both string[] (native tags) and string (migration from old CSV format).
const arr = Array.isArray(value)
? (value as string[])
: String(value ?? '')
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
return (
<ExecAllowlistEditor
value={arr}
onChange={(next) => onChange(next)}
disabled={disabled}
validate={validate}
suggestions={suggestions}
/>
)
}
case 'range': {
const numVal = Number(value) || 0
const min = field.min ?? 0
const max = field.max ?? 100
const step = field.step ?? 1
const decimals = step < 1 ? 2 : 0
return (
<div className="flex items-center gap-3 w-full">
<span className="text-xs text-muted-foreground tabular-nums w-6 text-right shrink-0">
{min}
</span>
<Slider
value={[numVal]}
min={min}
max={max}
step={step}
onValueChange={([v]) => onChange(String(v))}
disabled={disabled}
className="flex-1"
/>
<span className="text-xs text-muted-foreground tabular-nums w-6 shrink-0">{max}</span>
<span className="text-sm font-mono tabular-nums w-14 text-right shrink-0">
{numVal.toFixed(decimals)}
</span>
</div>
)
}
default: {
return (
<Input
id={id}
type="text"
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-mono"
disabled={disabled}
/>
)
}
}
}