// Control UI view renders config form screen content. import { html, nothing, type TemplateResult } from "lit"; import type { ConfigUiHints } from "../api/types.ts"; import { icons } from "../components/icons.ts"; import "../components/tooltip.ts"; import { t } from "../i18n/index.ts"; import { formatUnknownText } from "../lib/format.ts"; import { hasConfigSearchCriteria as hasSearchCriteria, matchesNodeSearch, matchesNodeSelf, resolveConfigFieldMeta as resolveFieldMeta, type ConfigSearchCriteria, } from "./config-form.search.ts"; import { defaultValue, hasSensitiveConfigData, hintForPath, pathKey, REDACTED_PLACEHOLDER, schemaType, type JsonSchema, } from "./config-form.shared.ts"; import { renderSettingsEmpty, renderSettingsSegmented, renderSettingsToggle, renderSettingsToggleRow, } from "./settings-ui.ts"; const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]); function isAnySchema(schema: JsonSchema): boolean { const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key)); return keys.length === 0; } function jsonValue(value: unknown): string { if (value === undefined) { return ""; } try { return JSON.stringify(value, null, 2) ?? ""; } catch { return ""; } } function formatComparablePrimitive(value: unknown): string | null { if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ) { return String(value); } return null; } function matchesComparablePrimitiveValue(left: unknown, right: unknown): boolean { if (Object.is(left, right)) { return true; } const leftComparable = formatComparablePrimitive(left); const rightComparable = formatComparablePrimitive(right); return leftComparable !== null && leftComparable === rightComparable; } function isSecretRefObject(value: unknown): value is { source: string; id: string; provider?: string; } { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } const candidate = value as Record; if (typeof candidate.source !== "string" || typeof candidate.id !== "string") { return false; } return candidate.provider === undefined || typeof candidate.provider === "string"; } type SensitiveRenderParams = { path: Array; value: unknown; hints: ConfigUiHints; revealSensitive: boolean; isSensitivePathRevealed?: (path: Array) => boolean; }; type SensitiveRenderState = { isSensitive: boolean; isRedacted: boolean; isRevealed: boolean; canReveal: boolean; }; function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState { const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints); const isRevealed = isSensitive && (params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false)); return { isSensitive, isRedacted: isSensitive && !isRevealed, isRevealed, canReveal: isSensitive, }; } function renderSensitiveToggleButton(params: { path: Array; state: SensitiveRenderState; disabled: boolean; onToggleSensitivePath?: (path: Array) => void; }): TemplateResult | typeof nothing { const { state } = params; if (!state.isSensitive || !params.onToggleSensitivePath) { return nothing; } const label = state.canReveal ? state.isRevealed ? t("configForm.hideValue") : t("configForm.revealValue") : t("configForm.disableStreamToReveal"); return html` `; } function renderTags(tags: string[]): TemplateResult | typeof nothing { if (tags.length === 0) { return nothing; } return html`
${tags.map((tag) => html`${tag}`)}
`; } type FieldRowParams = { label: unknown; help?: unknown; tags: string[]; showLabel: boolean; control: TemplateResult | typeof nothing; stacked?: boolean; error?: unknown; }; /** One settings-row: title/help/tags left, exactly one control cluster right. */ function renderFieldRow(params: FieldRowParams): TemplateResult { const hasText = params.showLabel || Boolean(params.help) || params.tags.length > 0 || Boolean(params.error); // Control-only rows (array/map item values) stack so the control gets full width. const stacked = params.stacked || !hasText; const className = stacked ? "settings-row settings-row--stacked" : "settings-row"; return html`
${hasText ? html`
${params.showLabel ? html`${params.label}` : nothing} ${params.help ? html`${params.help}` : nothing} ${renderTags(params.tags)} ${params.error ? html`${params.error}` : nothing}
` : nothing} ${params.control !== nothing ? html`
${params.control}
` : nothing}
`; } function renderSegmentedControl(params: { options: unknown[]; resolvedValue: unknown; disabled: boolean; ariaLabel: string; onSelect: (value: unknown) => void; }): TemplateResult { const selectedIndex = params.options.findIndex((option) => matchesComparablePrimitiveValue(option, params.resolvedValue), ); return renderSettingsSegmented({ value: selectedIndex < 0 ? "" : String(selectedIndex), options: params.options.map((option, index) => ({ value: String(index), label: formatUnknownText(option), })), disabled: params.disabled, ariaLabel: params.ariaLabel, onChange: (index) => { const option = params.options[Number(index)]; if (option !== undefined) { params.onSelect(option); } }, }); } export function renderNode(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult | typeof nothing { const { schema, value, path, hints, unsupported, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const type = schemaType(schema); const { label, help, tags } = resolveFieldMeta(path, schema, hints); const key = pathKey(path); const criteria = params.searchCriteria; if (unsupported.has(key)) { return renderFieldRow({ label, tags: [], showLabel: true, control: nothing, error: t("configForm.unsupportedNode"), }); } if ( criteria && hasSearchCriteria(criteria) && !matchesNodeSearch({ schema, value, path, hints, criteria }) ) { return nothing; } // Handle anyOf/oneOf unions if (schema.anyOf || schema.oneOf) { const variants = schema.anyOf ?? schema.oneOf ?? []; const nonNull = variants.filter( (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), ); if (nonNull.length === 1) { const selectedSchema = nonNull[0]; return selectedSchema ? renderNode({ ...params, schema: selectedSchema }) : nothing; } // Check if it's a set of literal values (enum-like) const extractLiteral = (v: JsonSchema): unknown => { if (v.const !== undefined) { return v.const; } if (v.enum && v.enum.length === 1) { return v.enum[0]; } return undefined; }; const literals = nonNull.map(extractLiteral); const allLiterals = literals.every((v) => v !== undefined); if (allLiterals && literals.length > 0 && literals.length <= 5) { // Use segmented control for small sets const resolvedValue = value ?? schema.default; return renderFieldRow({ label, help, tags, showLabel, control: renderSegmentedControl({ options: literals, resolvedValue, disabled, ariaLabel: label, onSelect: (literal) => onPatch(path, literal), }), }); } if (allLiterals && literals.length > 5) { // Use dropdown for larger sets return renderSelect({ ...params, options: literals, value: value ?? schema.default }); } // Handle mixed primitive types const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean)); const normalizedTypes = new Set( [...primitiveTypes].map((v) => (v === "integer" ? "number" : v)), ); if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) { const hasString = normalizedTypes.has("string"); const hasNumber = normalizedTypes.has("number"); const hasBoolean = normalizedTypes.has("boolean"); if (hasBoolean && normalizedTypes.size === 1) { return renderNode({ ...params, schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined }, }); } if (hasString || hasNumber) { return renderTextInput({ ...params, inputType: hasNumber && !hasString ? "number" : "text", }); } } // Complex union (e.g. array | object) — render as JSON textarea return renderJsonTextarea({ schema, value, path, hints, disabled, showLabel, revealSensitive: params.revealSensitive ?? false, isSensitivePathRevealed: params.isSensitivePathRevealed, onToggleSensitivePath: params.onToggleSensitivePath, onPatch, }); } // Enum - use segmented for small, dropdown for large if (schema.enum) { const options = schema.enum; if (options.length <= 5) { const resolvedValue = value ?? schema.default; return renderFieldRow({ label, help, tags, showLabel, control: renderSegmentedControl({ options, resolvedValue, disabled, ariaLabel: label, onSelect: (option) => onPatch(path, option), }), }); } return renderSelect({ ...params, options, value: value ?? schema.default }); } // Object type - collapsible section if (type === "object") { return renderObject(params); } // Array type if (type === "array") { return renderArray(params); } // Boolean - toggle row if (type === "boolean") { const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false; const onChange = (checked: boolean) => onPatch(path, checked); if (!showLabel) { // Control-only contexts (array items, map values) have no visible title, // so the switch keeps its accessible name from the field label. return renderFieldRow({ label, help, tags, showLabel, control: renderSettingsToggle({ checked: displayValue, disabled, ariaLabel: label, onChange, }), }); } const description = help || tags.length > 0 ? html`${help ?? nothing}${renderTags(tags)}` : undefined; return renderSettingsToggleRow({ title: label, description, checked: displayValue, disabled, onChange, }); } // Number/Integer if (type === "number" || type === "integer") { return renderNumberInput(params); } // String if (type === "string") { return renderTextInput({ ...params, inputType: "text" }); } // Fallback return renderFieldRow({ label, tags: [], showLabel: true, control: nothing, error: t("configForm.unsupportedType", { type: String(type) }), }); } function renderTextInput(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; inputType: "text" | "number"; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, disabled, onPatch, inputType } = params; const showLabel = params.showLabel ?? true; const hint = hintForPath(path, hints); const { label, help, tags } = resolveFieldMeta(path, schema, hints); const sensitiveState = getSensitiveRenderState({ path, value, hints, revealSensitive: params.revealSensitive ?? false, isSensitivePathRevealed: params.isSensitivePathRevealed, }); const isStructuredValue = value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value); const isStructuredSecretRef = isSecretRefObject(value); const rawAvailable = params.rawAvailable ?? true; const effectiveRedacted = sensitiveState.isRedacted || isStructuredSecretRef; const placeholder = effectiveRedacted ? isStructuredSecretRef ? rawAvailable ? t("configForm.structuredSecretRaw") : t("configForm.structuredSecretFile") : REDACTED_PLACEHOLDER : (hint?.placeholder ?? (schema.default !== undefined ? t("configForm.defaultValue", { value: formatUnknownText(schema.default) }) : "")); const displayValue = effectiveRedacted ? "" : isStructuredValue ? jsonValue(value) : (value ?? ""); const effectiveInputType = sensitiveState.isSensitive && !effectiveRedacted ? "text" : inputType; const control = html` { if (sensitiveState.isRedacted && !isStructuredSecretRef && params.onToggleSensitivePath) { params.onToggleSensitivePath(path); } }} @input=${(e: Event) => { if (effectiveRedacted) { return; } const raw = (e.target as HTMLInputElement).value; if (inputType === "number") { if (raw.trim() === "") { onPatch(path, undefined); return; } const parsed = Number(raw); onPatch(path, Number.isNaN(parsed) ? raw : parsed); return; } onPatch(path, raw); }} @change=${(e: Event) => { if (inputType === "number" || effectiveRedacted) { return; } const raw = (e.target as HTMLInputElement).value; onPatch(path, raw.trim()); }} /> ${isStructuredSecretRef ? nothing : renderSensitiveToggleButton({ path, state: sensitiveState, disabled, onToggleSensitivePath: params.onToggleSensitivePath, })} ${schema.default !== undefined ? html` ` : nothing} `; return renderFieldRow({ label, help, tags, showLabel, control }); } function renderNumberInput(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const displayValue = value ?? schema.default ?? ""; // Touch devices and some browsers hide native number spinners; keep explicit // one-step adjust buttons so single-step edits stay possible without typing. const step = (delta: number) => { if (disabled) { return; } const current = Number(displayValue); const base = Number.isFinite(current) ? current : 0; onPatch(path, base + delta); }; const control = html` { const raw = (e.target as HTMLInputElement).value; const parsed = raw === "" ? undefined : Number(raw); onPatch(path, parsed); }} /> `; return renderFieldRow({ label, help, tags, showLabel, control }); } function renderSelect(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; options: unknown[]; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, disabled, options, onPatch } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const resolvedValue = value ?? schema.default; const currentIndex = options.findIndex( (opt) => opt === resolvedValue || String(opt) === String(resolvedValue), ); const unset = "__unset__"; const control = html` `; return renderFieldRow({ label, help, tags, showLabel, control }); } function renderJsonTextareaControl(params: { path: Array; fallback: string; rows: number; sensitiveState: SensitiveRenderState; disabled: boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { path, fallback, sensitiveState, disabled, onPatch } = params; return html` ${renderSensitiveToggleButton({ path, state: sensitiveState, disabled, onToggleSensitivePath: params.onToggleSensitivePath, })} `; } function renderJsonTextarea(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; disabled: boolean; showLabel?: boolean; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const fallback = jsonValue(value); const sensitiveState = getSensitiveRenderState({ path, value, hints, revealSensitive: params.revealSensitive ?? false, isSensitivePathRevealed: params.isSensitivePathRevealed, }); return renderFieldRow({ label, help, tags, showLabel, stacked: true, control: renderJsonTextareaControl({ path, fallback, rows: 3, sensitiveState, disabled, onToggleSensitivePath: params.onToggleSensitivePath, onPatch, }), }); } function renderObject(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria, rawAvailable, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = searchCriteria && hasSearchCriteria(searchCriteria) ? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria }) : false; const childSearchCriteria = selfMatched ? undefined : searchCriteria; const fallback = value ?? schema.default; const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback) ? (fallback as Record) : {}; const props = schema.properties ?? {}; const entries = Object.entries(props); // Sort by hint order const sorted = entries.toSorted((a, b) => { const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0; const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0; if (orderA !== orderB) { return orderA - orderB; } return a[0].localeCompare(b[0]); }); const reserved = new Set(Object.keys(props)); const additional = schema.additionalProperties; const allowExtra = Boolean(additional) && typeof additional === "object"; const fields = html` ${sorted.map(([propKey, node]) => renderNode({ schema: node, value: obj[propKey], path: [...path, propKey], hints, rawAvailable, unsupported, disabled, searchCriteria: childSearchCriteria, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onPatch, }), )} ${allowExtra ? renderMapField({ schema: additional, value: obj, path, hints, rawAvailable, unsupported, disabled, reservedKeys: reserved, searchCriteria: childSearchCriteria, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onPatch, }) : nothing} `; // Top-level objects and label-less contexts emit rows directly into the // surrounding settings-group so row dividers stay sibling-driven. if (path.length === 1 || !showLabel) { return html`${fields}`; } // Nested objects get collapsible treatment as an indented sub-block. return html`
${label} ${help ? html`${help}` : nothing} ${renderTags(tags)}
${icons.chevronDown}
${fields}
`; } function renderArray(params: { schema: JsonSchema; value: unknown; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria, rawAvailable, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = searchCriteria && hasSearchCriteria(searchCriteria) ? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria }) : false; const childSearchCriteria = selfMatched ? undefined : searchCriteria; const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items; if (!itemsSchema) { return renderFieldRow({ label, tags: [], showLabel: true, control: nothing, error: t("configForm.unsupportedArray"), }); } const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : []; return html`
${showLabel ? html`${label}` : nothing} ${help ? html`${help}` : nothing} ${renderTags(tags)}
${t(arr.length === 1 ? "configForm.itemCountOne" : "configForm.itemCount", { count: String(arr.length), })}
${arr.length === 0 ? renderSettingsEmpty(t("configForm.noItems")) : html`
${arr.map( (item, idx) => html`
#${idx + 1}
${renderNode({ schema: itemsSchema, value: item, path: [...path, idx], hints, rawAvailable, unsupported, disabled, searchCriteria: childSearchCriteria, showLabel: false, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onPatch, })} `, )}
`}
`; } function renderMapField(params: { schema: JsonSchema; value: Record; path: Array; hints: ConfigUiHints; rawAvailable?: boolean; unsupported: Set; disabled: boolean; reservedKeys: Set; searchCriteria?: ConfigSearchCriteria; revealSensitive?: boolean; isSensitivePathRevealed?: (path: Array) => boolean; onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { schema, value, path, hints, rawAvailable, unsupported, disabled, reservedKeys, onPatch, searchCriteria, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, } = params; const anySchema = isAnySchema(schema); const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); const visibleEntries = searchCriteria && hasSearchCriteria(searchCriteria) ? entries.filter(([key, entryValue]) => matchesNodeSearch({ schema, value: entryValue, path: [...path, key], hints, criteria: searchCriteria, }), ) : entries; return html`
${t("configForm.customEntries")}
${visibleEntries.length === 0 ? renderSettingsEmpty(t("configForm.noCustomEntries")) : html`
${visibleEntries.map(([key, entryValue]) => { const valuePath = [...path, key]; const fallback = jsonValue(entryValue); const sensitiveState = getSensitiveRenderState({ path: valuePath, value: entryValue, hints, revealSensitive: revealSensitive ?? false, isSensitivePathRevealed, }); return html`
{ const nextKey = (e.target as HTMLInputElement).value.trim(); if (!nextKey || nextKey === key) { return; } const next = { ...value }; if (nextKey in next) { return; } next[nextKey] = next[key]; delete next[key]; onPatch(path, next); }} />
${anySchema ? renderFieldRow({ label: key, tags: [], showLabel: false, stacked: true, control: renderJsonTextareaControl({ path: valuePath, fallback, rows: 2, sensitiveState, disabled, onToggleSensitivePath, onPatch, }), }) : renderNode({ schema, value: entryValue, path: valuePath, hints, rawAvailable, unsupported, disabled, searchCriteria, showLabel: false, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, onPatch, })} `; })}
`}
`; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */