import { useEffect, useMemo, useState } from "react"; import { Effect, Exit } from "effect"; import { Button } from "@executor-js/react/components/button"; import { CardStack, CardStackContent, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; import { Checkbox } from "@executor-js/react/components/checkbox"; import { Label } from "@executor-js/react/components/label"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@executor-js/react/components/dialog"; import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; import { createCustomToolSourceEffect, formatSyncErrors, listCustomToolDirectoriesEffect, parseGitSourceUrl, slugifyCustomToolsAppName, suggestCustomToolsAppName, syncCustomToolSourceEffect, validateCustomToolsAppSlug, validateGitSourceUrl, type CustomToolsDirectoryListing, type AppSourceKind, } from "./custom-tools-client"; import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model"; export default function AddCustomToolsSource(props: { readonly onComplete: (slug?: string) => void; readonly onCancel: () => void; readonly initialUrl?: string; readonly initialNamespace?: string; readonly sourceKinds?: readonly AppSourceKind[]; }) { const sourceKinds = props.sourceKinds ?? ["git"]; const allowLocalDirectory = sourceKinds.includes("local-directory"); const initialKind = props.initialUrl && props.initialUrl.startsWith("/") && allowLocalDirectory ? "local-directory" : "git"; const [kind, setKind] = useState(initialKind); const [url, setUrl] = useState(props.initialUrl ?? ""); const [path, setPath] = useState( initialKind === "local-directory" ? (props.initialUrl ?? "") : "", ); const [ref, setRef] = useState(""); const [name, setName] = useState( props.initialNamespace ? slugifyCustomToolsAppName(props.initialNamespace) : "", ); const [nameTouched, setNameTouched] = useState(props.initialNamespace !== undefined); const [token, setToken] = useState(""); const [fieldError, setFieldError] = useState(null); const [nameError, setNameError] = useState(null); const [syncing, setSyncing] = useState(false); const [syncError, setSyncError] = useState(null); const [browseOpen, setBrowseOpen] = useState(false); const [pathListing, setPathListing] = useState(null); const sourceValue = kind === "git" ? url : path; const effectiveName = useMemo( () => (nameTouched ? name : slugifyCustomToolsAppName(suggestCustomToolsAppName(sourceValue))), [name, nameTouched, sourceValue], ); const slug = effectiveName; const slugAlreadyExists = useSlugAlreadyExists(slug); useEffect(() => { if (kind !== "local-directory" || !path.trim().startsWith("/")) { setPathListing(null); return; } let active = true; const timeout = window.setTimeout(() => { void Effect.runPromiseExit(listCustomToolDirectoriesEffect({ path: path.trim() })).then( (exit) => { if (!active) return; setPathListing(Exit.isSuccess(exit) ? exit.value : null); }, ); }, 300); return () => { active = false; window.clearTimeout(timeout); }; }, [kind, path]); const submit = async () => { const nextFieldError = kind === "git" ? validateGitSourceUrl(url) : path.trim().startsWith("/") ? null : "Enter an absolute directory path."; const nextNameError = validateCustomToolsAppSlug(slug); setFieldError(nextFieldError); setNameError(nextNameError); setSyncError(null); if (nextFieldError || nextNameError) return; if (slugAlreadyExists) { setSyncError(`An integration named "${slug}" already exists. Choose another source name.`); return; } setSyncing(true); const parsedGitUrl = kind === "git" ? parseGitSourceUrl(url) : null; const createExit = await Effect.runPromiseExit( createCustomToolSourceEffect( kind === "git" ? { kind: "git", slug, app: slug, url: parsedGitUrl?.ok ? parsedGitUrl.url : url.trim(), ...(ref.trim() ? { ref: ref.trim() } : {}), ...(token.trim() ? { token: token.trim() } : {}), } : { kind: "local-directory", slug, app: slug, path: path.trim() }, ), ); if (Exit.isFailure(createExit)) { setSyncError("Failed to create custom tools source."); setSyncing(false); return; } const syncExit = await Effect.runPromiseExit( syncCustomToolSourceEffect(createExit.value.source.slug), ); if (Exit.isFailure(syncExit)) { setSyncError("Failed to sync custom tools source."); setSyncing(false); return; } const result = syncExit.value; if (result.status === "failed") { setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); setSyncing(false); return; } setSyncing(false); props.onComplete(createExit.value.source.app); }; return (

Add custom tools

Sync tools from a Git repository, then publish them to the Apps catalog.

{allowLocalDirectory && (
)} {kind === "git" ? ( <>
{ setUrl((event.target as HTMLInputElement).value); setFieldError(null); setSyncError(null); }} onBlur={() => setFieldError(validateGitSourceUrl(url))} placeholder="https://github.com/acme/tools.git" className="font-mono text-sm" aria-invalid={fieldError ? true : undefined} /> {fieldError &&

{fieldError}

}
setRef((event.target as HTMLInputElement).value)} placeholder="main" className="font-mono text-sm" disabled={syncing} /> setToken((event.target as HTMLInputElement).value)} autoComplete="off" className="font-mono text-sm" disabled={syncing} /> ) : (
{ setPath((event.target as HTMLInputElement).value); setFieldError(null); setSyncError(null); }} onBlur={() => { if (!path.trim().startsWith("/")) return; void Effect.runPromiseExit( listCustomToolDirectoriesEffect({ path: path.trim() }), ).then((exit) => { setPathListing(Exit.isSuccess(exit) ? exit.value : null); }); }} placeholder="/Users/me/tools" className="font-mono text-sm" aria-invalid={fieldError ? true : undefined} />
{pathListing && } {fieldError &&

{fieldError}

}
)}
{ setNameTouched(true); setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value)); setNameError(null); setSyncError(null); }} placeholder="custom-tools" className="text-sm" aria-invalid={nameError ? true : undefined} /> {nameError &&

{nameError}

} {slugAlreadyExists && !syncing && !nameError && (

An integration named "{slug}" already exists.

)}
{syncError && } {browseOpen && ( setBrowseOpen(false)} onSelect={(selectedPath) => { setPath(selectedPath); setFieldError(null); setSyncError(null); setBrowseOpen(false); }} /> )}
); } function DirectoryBrowserModal(props: { readonly initialPath?: string; readonly onSelect: (path: string) => void; readonly onClose: () => void; }) { const [currentPath, setCurrentPath] = useState(props.initialPath); const [includeHidden, setIncludeHidden] = useState(false); const [listing, setListing] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let active = true; setLoading(true); setError(null); void Effect.runPromiseExit( listCustomToolDirectoriesEffect({ path: currentPath, includeHidden }), ).then((exit) => { if (!active) return; setLoading(false); if (Exit.isFailure(exit)) { setError("Unable to read this directory."); return; } setListing(exit.value); setCurrentPath(exit.value.path); }); return () => { active = false; }; }, [currentPath, includeHidden]); const rows = listing ? directoryBrowserRows(listing) : []; const selectedPath = listing?.path ?? currentPath ?? ""; const canSelect = Boolean(selectedPath) && !loading && !error; const hasToolFiles = (listing?.source.toolFiles.length ?? 0) > 0; return ( (open ? undefined : props.onClose())}> Choose directory {selectedPath || "Home"} {listing && }
{loading ? (
Loading...
) : error ? (
{error}
) : rows.length === 0 ? (
No subdirectories
) : (
{rows.map((row) => ( ))}
)}
); } function DirectorySourceStatus(props: { readonly listing: CustomToolsDirectoryListing }) { const verdict = directorySourceVerdict(props.listing); if (verdict.type === "valid") { return (

{verdict.message}:{" "} {verdict.visibleTools.map((tool, index) => ( {index > 0 ? ", " : ""} {tool} ))} {verdict.moreCount > 0 && ( <> {", "} +{verdict.moreCount} more )}

); } return

{verdict.message}

; }