292 lines · 10.1 KB · TypeScript
RawDownload
import { useCallback, useEffect, useState } from "react";import { Effect, Exit } from "effect"; import {  AlertDialog,  AlertDialogAction,  AlertDialogCancel,  AlertDialogContent,  AlertDialogDescription,  AlertDialogFooter,  AlertDialogHeader,  AlertDialogTitle,  AlertDialogTrigger,} from "@executor-js/react/components/alert-dialog";import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";import { Button } from "@executor-js/react/components/button";import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; import {  listCustomToolSourcesEffect,  removeCustomToolSourceEffect,  syncCustomToolSourceEffect,  type AppSourceRecord,} from "./custom-tools-client";import {  sourceFailureLines,  sourcePanelModel,  syncNoticeFromResult,  toolsCountLabel,  type SyncNoticeModel,} from "./source-panel-model"; const integrationsHref = (): string => {  const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/);  return `${match?.[1] ?? ""}/integrations`;}; type LoadState =  | { readonly status: "loading" }  | { readonly status: "error"; readonly message: string }  | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] }; export default function CustomToolsAccountsPanel(props: { readonly integrationId: string }) {  const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });  const [syncingSlug, setSyncingSlug] = useState<string | null>(null);  const [removingSlug, setRemovingSlug] = useState<string | null>(null);  const [notice, setNotice] = useState<Record<string, SyncNoticeModel | undefined>>({});  const [removeError, setRemoveError] = useState<string | null>(null);  const sourcesForApp = useCallback(    (sources: readonly AppSourceRecord[]): readonly AppSourceRecord[] =>      sources.filter((source) => source.app === props.integrationId),    [props.integrationId],  );   const loadSources = async () => {    setLoadState({ status: "loading" });    const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());    if (Exit.isFailure(exit)) {      setLoadState({ status: "error", message: "Failed to load custom tools sources." });      return;    }    setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) });  };   useEffect(() => {    let active = true;    void (async () => {      const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());      if (!active) return;      if (Exit.isFailure(exit)) {        setLoadState({ status: "error", message: "Failed to load custom tools sources." });        return;      }      setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) });    })();    return () => {      active = false;    };  }, [props.integrationId, sourcesForApp]);   const syncSource = async (source: AppSourceRecord) => {    setSyncingSlug(source.slug);    setNotice((current) => ({ ...current, [source.slug]: undefined }));    const beforeTools =      source.status.type === "published" || source.status.type === "up-to-date"        ? source.status.tools        : [];    const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect(source.slug));    if (Exit.isFailure(exit)) {      setNotice((current) => ({        ...current,        [source.slug]: {          status: "failed",          message: "Sync failed.",          added: [],          removed: [],          errors: ["Failed to sync custom tools source."],        },      }));      setSyncingSlug(null);      return;    }    setNotice((current) => ({      ...current,      [source.slug]: syncNoticeFromResult(exit.value, beforeTools),    }));    await loadSources();    setSyncingSlug(null);  };   const removeSource = async (source: AppSourceRecord) => {    setRemovingSlug(source.slug);    setRemoveError(null);    const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug));    if (Exit.isFailure(exit)) {      setRemoveError("Failed to remove custom tools source.");      setRemovingSlug(null);      return;    }    await loadSources();    setRemovingSlug(null);    window.location.assign(integrationsHref());  };   return (    <div className="mx-auto max-w-3xl space-y-6 px-6 py-8">      {loadState.status === "loading" && (        <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">          Loading sources...        </div>      )}       {loadState.status === "error" && (        <Alert variant="destructive">          <AlertTitle>Failed to load sources</AlertTitle>          <AlertDescription>            <div className="space-y-3">              <p>{loadState.message}</p>              <Button type="button" size="sm" variant="outline" onClick={() => void loadSources()}>                Retry              </Button>            </div>          </AlertDescription>        </Alert>      )}       {loadState.status === "ready" && loadState.sources.length === 0 && (        <div className="rounded-lg border border-border p-4">          <p className="text-sm font-medium text-foreground">No custom tool sources</p>          <p className="mt-1 text-sm text-muted-foreground">            Add a Git repository to publish tools into the Apps catalog.          </p>        </div>      )}       {loadState.status === "ready" &&        loadState.sources.map((source) => (          <SourceDetail            key={source.slug}            source={source}            notice={notice[source.slug] ?? null}            removeError={removeError}            syncing={syncingSlug === source.slug}            removing={removingSlug === source.slug}            onSync={() => void syncSource(source)}            onRemove={() => void removeSource(source)}          />        ))}    </div>  );} function SourceDetail(props: {  readonly source: AppSourceRecord;  readonly notice: SyncNoticeModel | null;  readonly removeError: string | null;  readonly syncing: boolean;  readonly removing: boolean;  readonly onSync: () => void;  readonly onRemove: () => void;}) {  const { source, notice } = props;  const model = sourcePanelModel(source);  const failureLines = sourceFailureLines(source);  const noticeHasDetails =    notice !== null &&    (notice.added.length > 0 ||      notice.removed.length > 0 ||      notice.errors.length > 0 ||      notice.sourceRef !== undefined);  return (    <div className="min-w-0 rounded-lg border border-border bg-card">      <div className="flex items-start justify-between gap-4 border-b border-border p-4">        <div className="min-w-0">          <h3 className="truncate text-sm font-medium text-foreground">{model.title}</h3>          <p className="mt-1 truncate text-xs text-muted-foreground">{model.source}</p>        </div>        <div className="flex shrink-0 gap-2">          <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}>            Sync          </Button>          <AlertDialog>            <AlertDialogTrigger asChild>              <Button                type="button"                size="sm"                variant="outline"                className="border-destructive/30 text-destructive hover:bg-destructive/10"                disabled={props.removing}              >                Remove              </Button>            </AlertDialogTrigger>            <AlertDialogContent size="sm">              <AlertDialogHeader>                <AlertDialogTitle>Remove {source.app}?</AlertDialogTitle>                <AlertDialogDescription>                  This removes {toolsCountLabel(model.tools.length)} from the catalog. The source is                  untouched and can be added again later.                </AlertDialogDescription>              </AlertDialogHeader>              <AlertDialogFooter>                <AlertDialogCancel>Cancel</AlertDialogCancel>                <AlertDialogAction variant="destructive" onClick={props.onRemove}>                  {props.removing ? "Removing..." : "Remove source"}                </AlertDialogAction>              </AlertDialogFooter>            </AlertDialogContent>          </AlertDialog>        </div>      </div>       <div className="space-y-5 p-4">        <div className="grid gap-3 text-sm sm:grid-cols-3">          <Meta label="Status" value={model.status} />          <Meta label="Source ref" value={model.sourceRef} mono />          <Meta label="Tools" value={toolsCountLabel(model.tools.length)} />        </div>         {failureLines.length > 0 && (          <Alert variant="destructive">            <AlertTitle>Last sync failed</AlertTitle>            <AlertDescription>              <div className="space-y-1">                {failureLines.map((line) => (                  <p key={line}>{line}</p>                ))}              </div>            </AlertDescription>          </Alert>        )}         {notice && (          <Alert variant={notice.status === "failed" ? "destructive" : "default"}>            <AlertTitle>{notice.message}</AlertTitle>            {noticeHasDetails && (              <AlertDescription>                <div className="space-y-1">                  {notice.added.length > 0 && <p>Added: {notice.added.join(", ")}</p>}                  {notice.removed.length > 0 && <p>Removed: {notice.removed.join(", ")}</p>}                  {notice.errors.map((error) => (                    <p key={error}>{error}</p>                  ))}                  {notice.sourceRef && (                    <p className="font-mono text-xs text-muted-foreground">                      Commit {notice.sourceRef}                    </p>                  )}                </div>              </AlertDescription>            )}          </Alert>        )}         {props.removeError && <FormErrorAlert message={props.removeError} />}      </div>    </div>  );} function Meta(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) {  return (    <div className="min-w-0">      <p className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">        {props.label}      </p>      <p className={props.mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate text-sm"}>        {props.value}      </p>    </div>  );}