/* Copyright 2026 Marimo. All rights reserved. */ import { ChevronDownIcon, KeyIcon, PlusCircleIcon, XIcon } from "lucide-react"; import React, { useState } from "react"; import { Command, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/utils/cn"; import { displaySecret, isSecret, prefixSecret } from "./secrets"; export function partitionSecretKeys( secretKeys: string[], optionRegex: string, ): { recommended: string[]; other: string[] } { if (!optionRegex) { return { recommended: [], other: [...secretKeys] }; } let pattern: RegExp; try { pattern = new RegExp(optionRegex, "i"); } catch { return { recommended: [], other: [...secretKeys] }; } const recommended: string[] = []; const other: string[] = []; for (const key of secretKeys) { if (pattern.test(key)) { recommended.push(key); } else { other.push(key); } } return { recommended, other }; } interface SecretComboboxProps { value: string; onChange: (value: string) => void; placeholder?: string; /** When true, only secrets (or creating one) can be chosen — no free-text literals. */ secretsOnly?: boolean; recommendedKeys: string[]; otherKeys: string[]; /** Opens the create-secret flow; `suggestedValue` is the current search text when present. */ onCreateSecret: (suggestedValue?: string) => void; className?: string; /** Placeholder for the search input inside the popover. */ searchPlaceholder?: string; /** Label for the "commit a free-text literal" item, e.g. `Use "${value}" as a path`. */ formatCustomValueLabel?: (value: string) => string; /** Label for the "create a new secret" item. */ createSecretLabel?: string; /** When false for the current search, hide the free-text / path option. */ allowCustomValue?: (search: string) => boolean; /** When false for the current search, hide the "create a new secret" option. */ allowCreateSecret?: (search: string) => boolean; } const defaultCustomValueLabel = (custom: string) => `Use "${custom}"`; /** * Searchable combobox for connection fields that may reference secrets. * * - Default: pick a secret, create one, or commit a free-text literal. * - `secretsOnly`: pick or create a secret only (for passwords/tokens/keys). */ export const SecretCombobox: React.FC = ({ value, onChange, placeholder, secretsOnly = false, recommendedKeys, otherKeys, onCreateSecret, className, searchPlaceholder, formatCustomValueLabel = defaultCustomValueLabel, createSecretLabel = "Create a new secret", allowCustomValue, allowCreateSecret, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const trimmedSearch = search.trim(); // Non-secret fields may commit a literal even when it collides with an // existing secret key, so we intentionally don't filter out matches here. const showCustomValue = !secretsOnly && trimmedSearch.length > 0 && trimmedSearch !== value && (allowCustomValue?.(trimmedSearch) ?? true); const showCreateSecret = allowCreateSecret?.(trimmedSearch) ?? true; const displayValue = (() => { if (!value) { return null; } if (isSecret(value)) { return displaySecret(value); } return value; })(); const selectSecret = (key: string) => { onChange(prefixSecret(key)); setOpen(false); setSearch(""); }; const selectCustom = (custom: string) => { onChange(custom); setOpen(false); setSearch(""); }; const clearValue = () => { onChange(""); setSearch(""); setOpen(false); }; return (
{ setOpen(next); if (!next) { setSearch(""); } }} > {showCustomValue && ( selectCustom(trimmedSearch)} > {formatCustomValueLabel(trimmedSearch)} )} {showCreateSecret && ( { const suggestedValue = trimmedSearch || undefined; setOpen(false); setSearch(""); onCreateSecret(suggestedValue); }} > {createSecretLabel} )} {recommendedKeys.length > 0 && ( <> {recommendedKeys.map((key) => ( selectSecret(key)} > {key} ))} )} {otherKeys.length > 0 && ( <> 0 ? "Other" : undefined} > {otherKeys.map((key) => ( selectSecret(key)} > {key} ))} )} {value && ( )}
); };