"use client"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; import { cn } from "@/lib/utils"; type InstallTab = "url" | "upload"; type InstallPluginDialogProps = { open: boolean; busy: boolean; error: string | null; onOpenChange: (open: boolean) => void; onSubmitUrl: (url: string) => void; onSubmitFile: (file: File) => void; }; /** * Replaces the old "Register plugin" manifest-paste dialog with the * install-based flow (docs/plans/plugins/GRPC-CONTRACT.md §7): install from a * remote tarball URL, or upload a .tar.gz package directly. No credentials * are ever shown — installing a plugin has nothing to copy or reveal. */ export function InstallPluginDialog({ open, busy, error, onOpenChange, onSubmitUrl, onSubmitFile, }: InstallPluginDialogProps) { const { t } = useTranslation(); const [tab, setTab] = useState("url"); const [url, setUrl] = useState(""); const [file, setFile] = useState(null); // Reset the lifted input state whenever the dialog closes. This keys off the // `open` prop rather than `onOpenChange` because a successful install closes // the dialog by flipping the controlled `open` prop directly // (usePluginActions.afterInstall), which never routes through onOpenChange — // so resetting only in an onOpenChange handler would leave a stale file/url // selected the next time the dialog opens. useEffect(() => { if (!open) { setUrl(""); setFile(null); setTab("url"); } }, [open]); const trimmedUrl = url.trim(); // The single Install button submits whichever tab is active; its testid, // handler, and disabled state all derive from `tab` so the e2e/unit selectors // (install-plugin-url-submit / install-plugin-upload-submit) keep working. const install = tab === "url" ? { testId: "install-plugin-url-submit", onClick: () => onSubmitUrl(trimmedUrl), disabled: busy || trimmedUrl.length === 0, } : { testId: "install-plugin-upload-submit", onClick: () => file && onSubmitFile(file), disabled: busy || !file, }; return ( {t("plugins:installPlugin")} {/* `.tar.gz` is a file extension, interpolated so the pseudo-locale cannot transliterate it. */} {t("plugins:installPluginDescription", { extension: ".tar.gz" })} setTab(v as InstallTab)}> {t("plugins:fromUrl")} {t("plugins:uploadFile")} {error && (
{error}
)}
); } function UrlTab({ url, setUrl }: { url: string; setUrl: (v: string) => void }) { const { t } = useTranslation(); return (
setUrl(e.target.value)} placeholder="https://example.com/acme-tools-1.0.0.tar.gz" className="font-mono text-sm" />
); } function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File | null) => void }) { const { t } = useTranslation(); const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }; const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const dropped = e.dataTransfer.files[0]; if (dropped) setFile(dropped); }; return (
inputRef.current?.click()} className={cn( "flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground cursor-pointer", isDragging && "border-primary bg-primary/5 ring-1 ring-primary/30", )} > {file ? ( {file.name} ) : ( t("plugins:dropzoneHint", { extension: ".tar.gz" }) )} setFile(e.target.files?.[0] ?? null)} className="hidden" />
); }