import { useState, useMemo, useCallback, useRef } from 'react'; import { Button, Input, SegmentedTabs, FileInput } from '@cameleer/design-system'; import type { FileInputHandle } from '@cameleer/design-system'; import { Plus, Copy, Trash2, FileUp } from 'lucide-react'; import styles from './EnvEditor.module.css'; import { type EnvEntry, type Format, parseText, toText, detectFormat, } from './env-convert'; interface EnvEditorProps { value: EnvEntry[]; onChange: (entries: EnvEntry[]) => void; disabled?: boolean; } const FORMAT_TABS = [ { label: 'Table', value: 'table' as const }, { label: 'Properties', value: 'properties' as const }, { label: 'YAML', value: 'yaml' as const }, { label: '.env', value: 'env' as const }, ]; type ViewMode = 'table' | Format; export function EnvEditor({ value, onChange, disabled }: EnvEditorProps) { const [view, setView] = useState('table'); const [textDraft, setTextDraft] = useState(null); const [showImport, setShowImport] = useState(false); const fileRef = useRef(null); // When switching views, convert between formats const handleViewChange = useCallback( (next: string) => { const nextView = next as ViewMode; // If leaving a text view, commit the draft if (textDraft !== null && view !== 'table') { const parsed = parseText(textDraft, view as Format); onChange(parsed); } // If entering a text view, generate text from current entries if (nextView !== 'table') { setTextDraft(toText(value, nextView as Format)); } else { setTextDraft(null); } setView(nextView); }, [view, textDraft, value, onChange], ); // Text for current format (memoized from entries when no draft) const displayText = useMemo(() => { if (textDraft !== null) return textDraft; if (view === 'table') return ''; return toText(value, view as Format); }, [textDraft, value, view]); // Commit text on blur const handleTextBlur = useCallback(() => { if (textDraft !== null && view !== 'table') { const parsed = parseText(textDraft, view as Format); onChange(parsed); } }, [textDraft, view, onChange]); // Table row handlers const updateRow = useCallback( (i: number, field: 'key' | 'value', val: string) => { const next = [...value]; next[i] = { ...next[i], [field]: val }; onChange(next); }, [value, onChange], ); const deleteRow = useCallback( (i: number) => onChange(value.filter((_, j) => j !== i)), [value, onChange], ); const addRow = useCallback( () => onChange([...value, { key: '', value: '' }]), [value, onChange], ); // Copy to clipboard const handleCopy = useCallback(() => { const text = view === 'table' ? toText(value, 'env') : displayText; navigator.clipboard.writeText(text); }, [view, value, displayText]); // Clear all const handleClear = useCallback(() => { onChange([]); setTextDraft(view !== 'table' ? '' : null); }, [view, onChange]); // File import const handleImportFile = useCallback(() => { const file = fileRef.current?.file; if (!file) return; const reader = new FileReader(); reader.onload = () => { const text = reader.result as string; const format = detectFormat(text); const parsed = parseText(text, format); // Merge with existing (dedupe by key, new values win) const existing = new Map(value.map((e) => [e.key, e])); for (const entry of parsed) { existing.set(entry.key, entry); } const merged = Array.from(existing.values()); onChange(merged); fileRef.current?.clear(); setShowImport(false); // If in text view, update the draft if (view !== 'table') { setTextDraft(toText(merged, view as Format)); } }; reader.readAsText(file); }, [value, onChange, view]); return (
{showImport && (
} />
)} {view === 'table' ? (
{value.map((entry, i) => (
updateRow(i, 'key', e.target.value)} className={styles.rowKey} placeholder="KEY" /> updateRow(i, 'value', e.target.value)} className={styles.rowValue} placeholder="value" />
))} {value.length === 0 && !disabled && (
No variables configured. Add one or import from a file.
)} {!disabled && ( )}
) : (