503 lines · 17.9 KB · TypeScript
RawDownload
import { useState, useEffect, useRef } from "react";import { useAtomSet } from "@effect-atom/atom-react";import { Option } from "effect"; import { useScope } from "@executor/react/api/scope-context";import {  SecretHeaderAuthRow,  defaultHeaderAuthPresets,} from "@executor/react/plugins/secret-header-auth";import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";import { Button } from "@executor/react/components/button";import { Input } from "@executor/react/components/input";import { Label } from "@executor/react/components/label";import { Textarea } from "@executor/react/components/textarea";import { Badge } from "@executor/react/components/badge";import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";import { 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 matchPresetKey(name: string, prefix?: string): string {  const preset =    defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === prefix) ??    defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === undefined);   return preset?.key ?? "custom";} function presetEntriesFromHeaderPreset(preset: HeaderPreset) {  return preset.secretHeaders.map((headerName) => {    const prefix = prefixForHeader(preset, headerName);    return {      name: headerName,      secretId: null as string | 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<string | null>(null);   // After analysis  const [preview, setPreview] = useState<SpecPreview | null>(null);  const [baseUrl, setBaseUrl] = useState("");  const [namespace, setNamespace] = useState(props.initialNamespace ?? "");  const [sourceName, setSourceName] = useState("");   // Auth  const [presetIndex, setPresetIndex] = useState(0);  const [customHeaders, setCustomHeaders] = useState<    Array<{      name: string;      secretId: string | null;      prefix?: string;      presetKey?: string;      fromPreset?: boolean;    }>  >([]);   // Submit  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(null);   const scopeId = useScope();  const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });  const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });  const secretList = useSecretPickerSecrets();  const autoAnalyzed = useRef(false);   useEffect(() => {    if (props.initialUrl && !autoAnalyzed.current) {      autoAnalyzed.current = true;      handleAnalyze();    }  }, []); // eslint-disable-line react-hooks/exhaustive-deps   // ---- Derived state ----   const presets = preview?.headerPresets ?? [];  const hasAuth = presets.length > 0;  const servers = (preview?.servers ?? []) as Array<{ url?: string }>;   const allHeaders: Record<string, HeaderValue> = {};  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);       // Derive defaults from the title      const title = Option.getOrElse(result.title, () => "api");      if (!sourceName) setSourceName(title);      if (!props.initialNamespace) {        setNamespace(          title            .toLowerCase()            .replace(/[^a-z0-9]+/g, "_")            .replace(/^_+|_+$/g, "") || "api",        );      }       const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;      if (firstUrl) setBaseUrl(firstUrl);       const newPresetIndex = result.headerPresets.length > 0 ? 0 : -1;      setPresetIndex(newPresetIndex);      setCustomHeaders(        newPresetIndex >= 0          ? presetEntriesFromHeaderPreset(result.headerPresets[newPresetIndex])          : [],      );    } catch (e) {      setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec");    } finally {      setAnalyzing(false);    }  };   const selectPreset = (index: number) => {    setPresetIndex(index);    if (index === -1) {      // "None" — clear everything      setCustomHeaders([]);    } else if (index === -2) {      // "Custom" — keep user headers, drop preset-derived, seed if empty      const userHeaders = customHeaders.filter((h) => !h.fromPreset);      setCustomHeaders(        userHeaders.length > 0 ? userHeaders : [{ name: "", secretId: null, presetKey: undefined }],      );    } else {      // Preset strategy — replace preset-derived headers, keep user headers      const preset = presets[index];      const userHeaders = customHeaders.filter((h) => !h.fromPreset);      setCustomHeaders(        preset ? [...presetEntriesFromHeaderPreset(preset), ...userHeaders] : userHeaders,      );    }  };   const addCustomHeader = () => {    if (presetIndex === -1) setPresetIndex(-2);    setCustomHeaders([...customHeaders, { name: "", secretId: null, presetKey: undefined }]);  };   const updateCustomHeader = (    index: number,    update: Partial<{ name: string; secretId: string | null; prefix?: string; presetKey?: string }>,  ) => {    setCustomHeaders(customHeaders.map((ch, i) => (i === index ? { ...ch, ...update } : ch)));  };   const removeCustomHeader = (index: number) => {    setCustomHeaders(customHeaders.filter((_, i) => i !== index));  };   const handleAdd = async () => {    setAdding(true);    setAddError(null);    try {      await doAdd({        path: { scopeId },        payload: {          spec: specUrl,          name: sourceName.trim() || undefined,          namespace: 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 (    <div className="space-y-6">      <h1 className="text-xl font-semibold text-foreground">Add OpenAPI Source</h1>       {/* ── Spec input ── */}      <section className="space-y-2">        <Label>OpenAPI Spec</Label>        <Textarea          value={specUrl}          onChange={(e) => {            setSpecUrl((e.target as HTMLTextAreaElement).value);            if (preview) {              setPreview(null);              setBaseUrl("");              setCustomHeaders([]);            }          }}          placeholder="https://api.example.com/openapi.json"          rows={3}          className="font-mono text-sm"        />         {analyzeError && (          <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">            <p className="text-[12px] text-destructive">{analyzeError}</p>          </div>        )}         {!preview && (          <div className="flex items-center justify-between">            <p className="text-[12px] text-muted-foreground">              Paste a URL or raw JSON/YAML content.            </p>            <Button disabled={!specUrl.trim() || analyzing} onClick={handleAnalyze}>              {analyzing && <Spinner className="size-3.5" />}              {analyzing ? "Analyzing…" : "Analyze"}            </Button>          </div>        )}      </section>       {/* ── Everything below appears after analysis ── */}      {preview && (        <>          {/* API info */}          <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">            <div className="min-w-0 flex-1">              <p className="text-sm font-semibold text-card-foreground leading-none truncate">                {Option.getOrElse(preview.title, () => "API")}              </p>              <p className="mt-1 text-[12px] text-muted-foreground leading-none">                {Option.getOrElse(preview.version, () => "")}                {Option.isSome(preview.version) && " · "}                {preview.operationCount} operation{preview.operationCount !== 1 ? "s" : ""}                {preview.tags.length > 0 &&                  ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}              </p>            </div>            {preview.tags.length > 0 && (              <div className="hidden sm:flex flex-wrap gap-1 max-w-[200px] justify-end">                {preview.tags.slice(0, 4).map((tag) => (                  <Badge key={tag} variant="secondary" className="text-[10px]">                    {tag}                  </Badge>                ))}                {preview.tags.length > 4 && (                  <span className="text-[10px] text-muted-foreground">                    +{preview.tags.length - 4}                  </span>                )}              </div>            )}          </div>           {/* Name */}          <section className="space-y-2">            <Label>Name</Label>            <Input              value={sourceName}              onChange={(e) => setSourceName((e.target as HTMLInputElement).value)}              placeholder="e.g. Sentry API"              className="text-[0.8125rem]"            />          </section>           {/* Namespace */}          <section className="space-y-2">            <Label>Namespace</Label>            <Input              value={namespace}              onChange={(e) =>                setNamespace(                  (e.target as HTMLInputElement).value.toLowerCase().replace(/[^a-z0-9_-]/g, "_"),                )              }              placeholder="e.g. sentry, stripe, github"              className="font-mono text-[0.8125rem]"            />            <p className="text-[0.75rem] text-muted-foreground">              Unique identifier for this source. Used in tool names.            </p>          </section>           {/* Base URL */}          <section className="space-y-2">            <Label>Base URL</Label>             {servers.length > 1 ? (              <div className="space-y-2">                <RadioGroup value={baseUrl} onValueChange={setBaseUrl} className="gap-1.5">                  {servers.map((s, i) => {                    const url = s.url ?? "";                    return (                      <Label                        key={i}                        className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                          baseUrl === url                            ? "border-primary/50 bg-primary/[0.03]"                            : "border-border hover:bg-accent/50"                        }`}                      >                        <RadioGroupItem value={url} />                        <span className="font-mono text-xs text-foreground truncate">{url}</span>                      </Label>                    );                  })}                </RadioGroup>                <Input                  value={baseUrl}                  onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}                  placeholder="Or enter a custom URL…"                  className="font-mono text-sm"                />              </div>            ) : (              <Input                value={baseUrl}                onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}                placeholder="https://api.example.com"                className="font-mono text-sm"              />            )}             {!baseUrl.trim() && (              <p className="text-[11px] text-amber-600 dark:text-amber-400">                A base URL is required to make requests.              </p>            )}          </section>           {/* Authentication */}          <section className="space-y-2.5">            <Label>Authentication</Label>             {/* Strategy picker */}            {hasAuth && (              <RadioGroup                value={String(presetIndex)}                onValueChange={(v) => selectPreset(Number(v))}                className="gap-1.5"              >                {presets.map((preset, i) => (                  <Label                    key={i}                    className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                      presetIndex === i                        ? "border-primary/50 bg-primary/[0.03]"                        : "border-border hover:bg-accent/50"                    }`}                  >                    <RadioGroupItem value={String(i)} />                    <span className="text-xs font-medium text-foreground">{preset.label}</span>                    {preset.secretHeaders.length > 0 && (                      <span className="ml-auto text-[10px] text-muted-foreground">                        {preset.secretHeaders.length} header                        {preset.secretHeaders.length > 1 ? "s" : ""}                      </span>                    )}                  </Label>                ))}                 <Label                  className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                    presetIndex === -2                      ? "border-primary/50 bg-primary/[0.03]"                      : "border-border hover:bg-accent/50"                  }`}                >                  <RadioGroupItem value="-2" />                  <span className="text-xs font-medium text-foreground">Custom</span>                  <span className="ml-auto text-[10px] text-muted-foreground">                    configure manually                  </span>                </Label>                 <Label                  className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                    presetIndex === -1                      ? "border-primary/50 bg-primary/[0.03]"                      : "border-border hover:bg-accent/50"                  }`}                >                  <RadioGroupItem value="-1" />                  <span className="text-xs font-medium text-foreground">None</span>                  <span className="ml-auto text-[10px] text-muted-foreground">skip auth</span>                </Label>              </RadioGroup>            )}             {/* All headers — preset-derived and user-added (hidden when None) */}            {presetIndex !== -1 && customHeaders.length > 0 && (              <div className="space-y-2">                {customHeaders.map((ch, i) => (                  <SecretHeaderAuthRow                    key={i}                    name={ch.name}                    prefix={ch.prefix}                    presetKey={ch.presetKey}                    secretId={ch.secretId}                    onChange={(update) => updateCustomHeader(i, update)}                    onSelectSecret={(secretId) => updateCustomHeader(i, { secretId })}                    onRemove={() => removeCustomHeader(i)}                    existingSecrets={secretList}                  />                ))}              </div>            )}             {(!hasAuth || presetIndex === -2) && (              <Button                variant="outline"                size="sm"                className="w-full border-dashed"                onClick={addCustomHeader}              >                + Add header              </Button>            )}          </section>           {/* Add error */}          {addError && (            <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">              <p className="text-[12px] text-destructive">{addError}</p>            </div>          )}           {/* Actions */}          <div className="flex items-center justify-between border-t border-border pt-4">            <Button variant="ghost" onClick={props.onCancel} disabled={adding}>              Cancel            </Button>            <Button onClick={handleAdd} disabled={!canAdd || adding}>              {adding && <Spinner className="size-3.5" />}              {adding ? "Adding…" : "Add source"}            </Button>          </div>        </>      )}       {/* Cancel when no preview yet */}      {!preview && (        <div className="flex items-center justify-between pt-1">          <Button variant="ghost" onClick={props.onCancel}>            Cancel          </Button>          <div />        </div>      )}

Lines 1–500 of 503.