import { Button } from "@/components/ui/button"; import { CodeEditor } from "@/components/ui/codeEditor"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useGetCoreConfigQuery } from "@/lib/store"; import { Info, Upload } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { detectSpecLanguage, formatBytes, isSpecTooLarge, OPENAPI_AUTO_PARSE_DELAY_MS, openAPIMaxSpecBytes, autoParseStep, openAPIParseControls, applyUploadedSpec, readUploadedSpec, type UploadErrorState, visibleUploadError, type OpenAPISourceMode, } from "./mcpClientOpenApiUtils"; export interface OpenAPISpecSourceProps { mode: OpenAPISourceMode; onModeChange: (mode: OpenAPISourceMode) => void; specText: string; onSpecTextChange: (text: string) => void; specUrl: string; onSpecUrlChange: (url: string) => void; fileName: string | null; onFileSelected: (file: File, text: string) => void; onParse: () => void; isParsing: boolean; parseError: string | null; /** The preview no longer matches the source (text or URL edited after parsing). */ isStale: boolean; /** The preview describes the current source. */ isCurrent: boolean; /** Identifies the source a parse would describe; a new key re-arms auto-parsing. */ sourceKey: string; specError?: string; specUrlError?: string; disabled?: boolean; /** Narrower copy for the edit sheet's "Replace spec" card. */ compact?: boolean; /** Which sources to offer; a surface without a server-side fetch leaves out "url". */ modes?: OpenAPISourceMode[]; /** What happens to a fetched document on this surface, under the URL field; defaults to the MCP wording. */ urlHint?: ReactNode; } const ALL_SOURCE_MODES: OpenAPISourceMode[] = ["paste", "url", "upload"]; const SOURCE_MODE_LABELS: Record = { paste: "Paste", url: "URL", upload: "Upload" }; /** * The three ways to hand Bifrost an OpenAPI document: paste it, point at a URL, * or upload a file. The text itself always lands in `specText` (an upload is * read client-side), so the API only ever receives JSON. A pasted or uploaded * document is parsed on its own once the text settles; a URL is fetched on * request. The preview is what the operation picker is built from. */ export function OpenAPISpecSource({ mode, onModeChange, specText, onSpecTextChange, specUrl, onSpecUrlChange, fileName, onFileSelected, onParse, isParsing, parseError, isStale, isCurrent, sourceKey, specError, specUrlError, disabled, compact, modes = ALL_SOURCE_MODES, urlHint, }: OpenAPISpecSourceProps) { const fileInputRef = useRef(null); // The server enforces the configured limit; this only spares a doomed upload. A caller who // cannot read the config gets the default. const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); const maxSpecBytes = openAPIMaxSpecBytes(bifrostConfig?.client_config?.mcp_openapi_max_spec_mb); const sourceEmpty = mode === "url" ? !specUrl.trim() : !specText.trim(); // Encoding a large upload is not free, so measure it once per text change rather than on every render. const specBytes = useMemo(() => new TextEncoder().encode(specText).length, [specText]); const uploadTooLarge = mode === "upload" && specText && isSpecTooLarge(specBytes, maxSpecBytes); const [uploadError, setUploadError] = useState(null); // Bumped by every new file, source switch and text edit, so a slow read that // finishes after any of them cannot overwrite the newer source. const readToken = useRef(0); // Unmounting (the replacement was cancelled) invalidates a read still in // flight, so it cannot land in a draft opened afterwards. useEffect( () => () => { readToken.current++; }, [], ); const handleFile = async (file: File | undefined) => { if (!file) return; const token = ++readToken.current; const result = await readUploadedSpec(file, maxSpecBytes); if (token !== readToken.current) return; // A rejected file keeps the document already loaded. const { accept, error } = applyUploadedSpec(fileName ?? "", result); setUploadError(error); if (accept) onFileSelected(file, result.text); }; const handleModeChange = (next: OpenAPISourceMode) => { readToken.current++; setUploadError(null); onModeChange(next); }; const handleSpecTextChange = (text: string) => { readToken.current++; setUploadError(null); onSpecTextChange(text); }; const shownUploadError = visibleUploadError(uploadError, fileName ?? ""); const controls = openAPIParseControls({ mode, isCurrent, hasError: !!parseError, canParse: !disabled && !sourceEmpty && !uploadTooLarge, isParsing, }); // Each source is auto-parsed once: a failed parse waits for an edit (or the // retry button) instead of firing again. const autoParsedKey = useRef(null); const onParseRef = useRef(onParse); // Updated after commit, so a timer never calls a callback from a render // that was discarded. useEffect(() => { onParseRef.current = onParse; }, [onParse]); const shouldAutoParse = controls.autoParse && !disabled && !sourceEmpty && !uploadTooLarge && !isCurrent; useEffect(() => { const step = autoParseStep(autoParsedKey.current, sourceKey, shouldAutoParse); if (!step.parse) { autoParsedKey.current = step.remembered; return; } const timer = setTimeout(() => { autoParsedKey.current = step.remembered; onParseRef.current(); }, OPENAPI_AUTO_PARSE_DELAY_MS); return () => clearTimeout(timer); }, [shouldAutoParse, sourceKey]); // A URL is fetched from the button beside it; the other sources keep their retry button below. const parseButton = ( ); return (
handleModeChange(value as OpenAPISourceMode)}> {modes.map((m) => ( {SOURCE_MODE_LABELS[m]} ))}

Paste the OpenAPI or Swagger document as JSON or YAML (2.0, 3.0, 3.1 and 3.2 are supported).

onSpecUrlChange(e.target.value)} aria-invalid={!!specUrlError} /> {controls.showButton && parseButton}

{urlHint ?? "Bifrost fetches the document once and stores it with the server; the URL is kept as its source."}

{ void handleFile(e.target.files?.[0]); e.target.value = ""; }} /> {fileName && ( {fileName} {specText ? ` · ${formatBytes(specBytes)}` : ""} )}
{shownUploadError || uploadTooLarge ? (

{shownUploadError ?? `The file exceeds the ${formatBytes(maxSpecBytes)} limit for OpenAPI documents.`}

) : (

JSON or YAML, up to {formatBytes(maxSpecBytes)}. The file is read in your browser and stored with the server.

)}
{specError && mode !== "url" && (

{specError}

)} {specUrlError && mode === "url" && (

{specUrlError}

)}
{controls.showPending && (

Parsing the document…

)} {mode === "url" && isStale && !parseError && (

The URL or server name changed since the document was fetched. Fetch it again to refresh the operations below.

)} {parseError && (

{parseError}

)}
{controls.showButton && mode !== "url" && parseButton}
); }