"use client"; import { CheckIcon, ClipboardPasteIcon, EyeIcon, EyeOffIcon, LockIcon, PlusIcon, Trash2Icon, Undo2Icon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ClipboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export interface EnvEnvironment { id: string; name: string; } export interface EnvVariable { key: string; values: Record; secret?: boolean; } export interface EnvEditorProps { environments: EnvEnvironment[]; variables: EnvVariable[]; project?: string; onSave?: (variables: EnvVariable[]) => void | Promise; className?: string; } type Row = EnvVariable & { uid: string; removed?: boolean }; const MASK = "••••••••"; const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const spring = { type: "spring", stiffness: 480, damping: 38 } as const; const isSecret = (v: EnvVariable) => v.secret ?? (!/^(NEXT_PUBLIC_|PUBLIC_|VITE_)/.test(v.key) && /SECRET|TOKEN|KEY|PASSWORD|_URL$|DSN/.test(v.key)); let seq = 0; const toRows = (vars: EnvVariable[]): Row[] => vars.map((v) => ({ ...v, values: { ...v.values }, uid: `v${++seq}` })); function parseDotenv(text: string) { const entries: [string, string][] = []; let skipped = 0; for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const m = line.replace(/^export\s+/, "").match(/^([^=\s]+)\s*=\s*(.*)$/); if (!m || !KEY_RE.test(m[1])) { skipped++; continue; } let value = m[2].replace(/\s+#.*$/, ""); if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1); entries.push([m[1], value]); } return { entries, skipped }; } function IconButton({ label, onClick, children, className }: { label: string; onClick: () => void; children: React.ReactNode; className?: string }) { return ( } > {children} {label} ); } export function EnvEditor({ environments, variables, project, onSave, className }: EnvEditorProps) { const reduce = useReducedMotion(); const [baseline, setBaseline] = useState(() => toRows(variables)); const [rows, setRows] = useState(() => baseline.map((r) => ({ ...r, values: { ...r.values } }))); const [revealed, setRevealed] = useState>(new Set()); const [editing, setEditing] = useState<{ uid: string; env: string } | null>(null); const [draft, setDraft] = useState(""); const [importOpen, setImportOpen] = useState(false); const [importText, setImportText] = useState(""); const [importEnv, setImportEnv] = useState(environments[0]?.id ?? ""); const [saving, setSaving] = useState<"idle" | "saving" | "saved">("idle"); const [newKey, setNewKey] = useState(null); const keyInput = useRef(null); const base = useMemo(() => new Map(baseline.map((r) => [r.uid, r])), [baseline]); const cellChanged = (r: Row, env: string) => { const b = base.get(r.uid); return !b || (b.values[env] ?? "") !== (r.values[env] ?? ""); }; const changes = useMemo(() => { let added = 0; let removed = 0; let edited = 0; for (const r of rows) { const b = base.get(r.uid); if (!b) { if (!r.removed) added++; } else if (r.removed) removed++; else if (environments.some((e) => (b.values[e.id] ?? "") !== (r.values[e.id] ?? ""))) edited++; } return { added, removed, edited, total: added + removed + edited }; }, [rows, base, environments]); useEffect(() => { if (saving !== "saved") return; const t = setTimeout(() => setSaving("idle"), 1600); return () => clearTimeout(t); }, [saving]); useEffect(() => { if (newKey !== null) keyInput.current?.focus(); }, [newKey]); const allRevealed = rows.filter(isSecret).every((r) => revealed.has(r.uid)); const startEdit = (r: Row, env: string) => { if (r.removed) return; setEditing({ uid: r.uid, env }); setDraft(r.values[env] ?? ""); }; const commitEdit = (save: boolean) => { if (!editing) return; if (save) { setRows((rs) => rs.map((r) => (r.uid === editing.uid ? { ...r, values: { ...r.values, [editing.env]: draft === "" ? undefined : draft } } : r)), ); } setEditing(null); }; const parsed = useMemo(() => parseDotenv(importText), [importText]); const importSummary = useMemo(() => { let fresh = 0; let updated = 0; for (const [k, v] of parsed.entries) { const row = rows.find((r) => r.key === k && !r.removed); if (!row) fresh++; else if ((row.values[importEnv] ?? "") !== v) updated++; } return { fresh, updated }; }, [parsed, rows, importEnv]); const applyImport = () => { setRows((rs) => { const next = rs.map((r) => ({ ...r, values: { ...r.values } })); for (const [k, v] of parsed.entries) { const row = next.find((r) => r.key === k && !r.removed); if (row) row.values[importEnv] = v; else next.push({ key: k, values: { [importEnv]: v }, uid: `v${++seq}` }); } return next; }); setImportText(""); setImportOpen(false); }; const onPaste = (e: ClipboardEvent) => { const target = e.target as HTMLElement; if (target.closest("input, textarea")) return; const text = e.clipboardData.getData("text"); if (!/^\s*(export\s+)?[A-Za-z_]\w*\s*=/m.test(text)) return; e.preventDefault(); setImportText(text); setImportOpen(true); }; const addVariable = () => { const key = (newKey ?? "").trim().toUpperCase(); if (!KEY_RE.test(key) || rows.some((r) => r.key === key && !r.removed)) return; const row: Row = { key, values: {}, uid: `v${++seq}` }; setRows((rs) => [...rs, row]); setNewKey(null); setTimeout(() => startEdit(row, environments[0].id), 0); }; const discard = () => { setRows(baseline.map((r) => ({ ...r, values: { ...r.values } }))); setEditing(null); }; const save = async () => { setSaving("saving"); const kept = rows.filter((r) => !r.removed); const clean = kept.map(({ key, values, secret }) => ({ key, values, secret })); await onSave?.(clean); setBaseline(kept.map((r) => ({ ...r, values: { ...r.values } }))); setRows(kept); setSaving("saved"); }; const newKeyError = newKey && newKey.trim() ? !KEY_RE.test(newKey.trim()) ? "Letters, digits and _ only" : rows.some((r) => r.key === newKey.trim().toUpperCase() && !r.removed) ? "Already exists" : null : null; const cols = `minmax(224px,1.5fr) repeat(${environments.length}, minmax(118px,1fr)) 36px`; const showFooter = changes.total > 0 || saving !== "idle"; return (

Environment variables

{project ? `${project} · ` : ""} {rows.filter((r) => !r.removed).length} variables · paste a .env anywhere here

setRevealed(allRevealed ? new Set() : new Set(rows.map((r) => r.uid)))}> {allRevealed ? : }
{importOpen && (