import { useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { Option } from "effect"; import { useScope } from "@executor/react/api/scope-context"; import { HeadersList } from "@executor/react/plugins/headers-list"; import { matchPresetKey, type HeaderState, } from "@executor/react/plugins/secret-header-auth"; import { SourceIdentityFields, useSourceIdentity, } from "@executor/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; import { CardStack, CardStackContent, CardStackEntry, CardStackEntryContent, CardStackEntryDescription, CardStackEntryField, CardStackEntryTitle, } from "@executor/react/components/card-stack"; import { FieldLabel } from "@executor/react/components/field"; import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { Textarea } from "@executor/react/components/textarea"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; import { Skeleton } from "@executor/react/components/skeleton"; import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; import type { SpecPreview, HeaderPreset } from "../sdk/preview"; import type { HeaderValue } from "../sdk/types"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function prefixForHeader(preset: HeaderPreset, headerName: string): string | undefined { const label = preset.label.toLowerCase(); if (headerName.toLowerCase() === "authorization") { if (label.includes("bearer")) return "Bearer "; if (label.includes("basic")) return "Basic "; } return undefined; } function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { return preset.secretHeaders.map((headerName) => { const prefix = prefixForHeader(preset, headerName); return { name: headerName, secretId: null, prefix, presetKey: matchPresetKey(headerName, prefix), fromPreset: true, }; }); } // --------------------------------------------------------------------------- // Main component — single progressive form // --------------------------------------------------------------------------- export default function AddOpenApiSource(props: { onComplete: () => void; onCancel: () => void; initialUrl?: string; initialNamespace?: string; }) { // Spec input const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); const identity = useSourceIdentity({ fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", fallbackNamespace: props.initialNamespace, }); // Auth // `selectedStrategy` is an index into `preview.headerPresets`, or -1 for // "None", or -2 for "Custom" (user-managed headers, no spec preset). const [selectedStrategy, setSelectedStrategy] = useState(-1); const [customHeaders, setCustomHeaders] = useState([]); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const scopeId = useScope(); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); const secretList = useSecretPickerSecrets(); // Keep the latest handleAnalyze in a ref so the debounced effect doesn't // need it as a dependency (it closes over fresh state). const handleAnalyzeRef = useRef<() => void>(() => {}); // Auto-analyze whenever the spec input changes, with a short debounce so // typing/pasting doesn't fire a request on every keystroke. useEffect(() => { const trimmed = specUrl.trim(); if (!trimmed) return; if (preview) return; const handle = setTimeout(() => { handleAnalyzeRef.current(); }, 400); return () => clearTimeout(handle); }, [specUrl, preview]); // ---- Derived state ---- const servers = (preview?.servers ?? []) as Array<{ url?: string }>; // Derive a favicon URL from the spec URL (if the user entered one — raw // JSON/YAML content will fail URL parsing and yield null). Uses Google's // favicon service so we don't depend on the domain serving /favicon.ico. const faviconUrl = useMemo(() => { try { const trimmed = specUrl.trim(); if (!trimmed) return null; const u = new URL(trimmed); if (u.protocol !== "http:" && u.protocol !== "https:") return null; return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`; } catch { return null; } }, [specUrl]); const [faviconFailed, setFaviconFailed] = useState(false); useEffect(() => { setFaviconFailed(false); }, [faviconUrl]); const allHeaders: Record = {}; for (const ch of customHeaders) { if (ch.name.trim() && ch.secretId) { allHeaders[ch.name.trim()] = { secretId: ch.secretId, ...(ch.prefix ? { prefix: ch.prefix } : {}), }; } } const hasHeaders = Object.keys(allHeaders).length > 0; const customHeadersValid = customHeaders.every((ch) => ch.name.trim() && ch.secretId); const canAdd = preview !== null && baseUrl.trim().length > 0 && (customHeaders.length === 0 || customHeadersValid); // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); try { const result = await doPreview({ path: { scopeId }, payload: { spec: specUrl }, }); setPreview(result); const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url; if (firstUrl) setBaseUrl(firstUrl); const firstPreset = result.headerPresets[0]; if (firstPreset) { setSelectedStrategy(0); setCustomHeaders(entriesFromSpecPreset(firstPreset)); } else { setSelectedStrategy(-1); setCustomHeaders([]); } } catch (e) { setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec"); } finally { setAnalyzing(false); } }; handleAnalyzeRef.current = handleAnalyze; const selectStrategy = (index: number) => { setSelectedStrategy(index); if (index === -1) { setCustomHeaders([]); return; } if (index === -2) { // Drop preset-derived headers, keep user headers (seed one if empty). const userHeaders = customHeaders.filter((h) => !h.fromPreset); setCustomHeaders(userHeaders.length > 0 ? userHeaders : []); return; } const preset = preview?.headerPresets[index]; if (!preset) return; const userHeaders = customHeaders.filter((h) => !h.fromPreset); setCustomHeaders([...entriesFromSpecPreset(preset), ...userHeaders]); }; const handleHeadersChange = (next: HeaderState[]) => { setCustomHeaders(next); // If user drops all preset-derived headers and adds their own, mark as // Custom so the strategy picker reflects it. if (selectedStrategy >= 0 && next.every((h) => !h.fromPreset)) { setSelectedStrategy(next.length === 0 ? -1 : -2); } }; const handleAdd = async () => { setAdding(true); setAddError(null); try { await doAdd({ path: { scopeId }, payload: { spec: specUrl, name: identity.name.trim() || undefined, namespace: identity.namespace.trim() || undefined, baseUrl: baseUrl.trim() || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), }, }); props.onComplete(); } catch (e) { setAddError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } }; // ---- Render ---- return (

Add OpenAPI Source

{/* ── Spec input ── */}