Particular.Tech
Developerui

Env var editor

Edit environment variables; paste a .env file to import.

Tip: paste a .env file into any key field to import it.

Installation

npx shadcn@latest add @particular/env-var-editor
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0
Installs these registry items

Source

ui/env-var-editor.tsx
"use client"

import * as React from "react"
import { PlusIcon, Trash2Icon } from "lucide-react"
import { cn } from "cn"

import {
  isValidEnvKey,
  mergeEnvVars,
  parseDotenv,
  type EnvVar,
} from "@/lib/env-parser"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SecretInput } from "@/components/ui/secret-input"

type EnvVarEditorProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  value: EnvVar[]
  onChange: (value: EnvVar[]) => void
  /** Mask values until revealed. */
  sensitive?: boolean
  disabled?: boolean
}

function looksLikeDotenv(text: string) {
  return text.includes("\n") || /^\s*(export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/.test(text)
}

function EnvVarEditor({
  value,
  onChange,
  sensitive = true,
  disabled = false,
  className,
  ...props
}: EnvVarEditorProps) {
  const [importMessage, setImportMessage] = React.useState<string | null>(null)
  const keyCounts = new Map<string, number>()
  for (const entry of value) keyCounts.set(entry.key, (keyCounts.get(entry.key) ?? 0) + 1)

  const update = (index: number, patch: Partial<EnvVar>) =>
    onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))

  const handlePaste = (index: number, event: React.ClipboardEvent<HTMLInputElement>) => {
    const text = event.clipboardData.getData("text")
    if (!looksLikeDotenv(text)) return
    event.preventDefault()
    const result = parseDotenv(text)
    const withoutBlankRow = value.filter((entry, i) => i !== index || entry.key || entry.value)
    onChange(mergeEnvVars(withoutBlankRow, result.entries))
    const parts = [`Imported ${result.entries.length} variable${result.entries.length === 1 ? "" : "s"}`]
    if (result.errors.length) {
      parts.push(
        `skipped ${result.errors.length}: ${result.errors.map((e) => `line ${e.line} ${e.message}`).join("; ")}`
      )
    }
    setImportMessage(parts.join(", "))
  }

  return (
    <div data-slot="env-var-editor" className={cn("flex flex-col gap-3", className)} {...props}>
      <div className="hidden grid-cols-[minmax(0,2fr)_minmax(0,3fr)_2rem] gap-2 px-0.5 text-xs font-medium text-muted-foreground sm:grid">
        <span>Key</span>
        <span>Value</span>
      </div>
      <ul className="flex flex-col gap-2">
        {value.map((entry, index) => {
          const invalid = entry.key !== "" && !isValidEnvKey(entry.key)
          const duplicate = entry.key !== "" && (keyCounts.get(entry.key) ?? 0) > 1
          const errorId = `env-var-${index}-error`
          return (
            <li
              key={index}
              className="grid grid-cols-[minmax(0,1fr)_2rem] gap-2 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_2rem]"
            >
              <Input
                aria-label={`Key ${index + 1}`}
                aria-invalid={invalid || duplicate || undefined}
                aria-describedby={invalid || duplicate ? errorId : undefined}
                placeholder="EXAMPLE_KEY"
                value={entry.key}
                disabled={disabled}
                spellCheck={false}
                autoComplete="off"
                className="font-mono text-[0.8125rem]"
                onChange={(event) => update(index, { key: event.target.value })}
                onPaste={(event) => handlePaste(index, event)}
              />
              <div className="col-start-1 row-start-2 sm:col-start-auto sm:row-start-auto">
                {sensitive ? (
                  <SecretInput
                    aria-label={`Value ${index + 1}`}
                    placeholder="value"
                    value={entry.value}
                    disabled={disabled}
                    copyable={false}
                    onChange={(event) => update(index, { value: event.target.value })}
                  />
                ) : (
                  <Input
                    aria-label={`Value ${index + 1}`}
                    placeholder="value"
                    value={entry.value}
                    disabled={disabled}
                    className="font-mono text-[0.8125rem]"
                    onChange={(event) => update(index, { value: event.target.value })}
                  />
                )}
              </div>
              <Button
                variant="ghost"
                size="icon"
                aria-label={`Remove ${entry.key || `row ${index + 1}`}`}
                disabled={disabled}
                className="row-span-2 text-muted-foreground hover:text-destructive sm:row-span-1"
                onClick={() => onChange(value.filter((_, i) => i !== index))}
              >
                <Trash2Icon />
              </Button>
              {(invalid || duplicate) && (
                <p id={errorId} className="col-span-full -mt-1 text-xs text-destructive">
                  {invalid
                    ? "Keys start with a letter or underscore and contain only letters, digits, and underscores."
                    : `${entry.key} is defined more than once.`}
                </p>
              )}
            </li>
          )
        })}
      </ul>
      <div className="flex flex-wrap items-center justify-between gap-2">
        <Button
          variant="outline"
          size="sm"
          disabled={disabled}
          onClick={() => onChange([...value, { key: "", value: "" }])}
        >
          <PlusIcon data-icon="inline-start" />
          Add variable
        </Button>
        <p aria-live="polite" className="text-xs text-muted-foreground">
          {importMessage ?? "Tip: paste a .env file into any key field to import it."}
        </p>
      </div>
    </div>
  )
}

export { EnvVarEditor, type EnvVarEditorProps }