feat(frontend): add OpencodePluginsModal component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
264
src/components/settings/opencode-plugins-modal.tsx
Normal file
264
src/components/settings/opencode-plugins-modal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Download, Loader2, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
opencodeListPlugins,
|
||||
opencodeInstallPlugins,
|
||||
opencodeUninstallPlugin,
|
||||
} from "@/lib/api"
|
||||
import { usePluginInstallStream } from "@/hooks/use-plugin-install-stream"
|
||||
import type { PluginCheckSummary, PluginInfo } from "@/lib/types"
|
||||
|
||||
interface OpencodePluginsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCompleted?: () => void
|
||||
}
|
||||
|
||||
export function OpencodePluginsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCompleted,
|
||||
}: OpencodePluginsModalProps) {
|
||||
const t = useTranslations("AcpAgentSettings.opencodePlugins")
|
||||
const [summary, setSummary] = useState<PluginCheckSummary | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uninstalling, setUninstalling] = useState<string | null>(null)
|
||||
const stream = usePluginInstallStream()
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isOperating = stream.status === "running" || uninstalling !== null
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await opencodeListPlugins()
|
||||
setSummary(result)
|
||||
} catch (err) {
|
||||
console.error("[OpencodePlugins] Failed to list plugins:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refresh()
|
||||
stream.reset()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [stream.logs])
|
||||
|
||||
useEffect(() => {
|
||||
if (stream.status === "success" || stream.status === "failed") {
|
||||
refresh()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stream.status])
|
||||
|
||||
const handleInstallAll = useCallback(async () => {
|
||||
const taskId = crypto.randomUUID()
|
||||
await stream.start(taskId)
|
||||
try {
|
||||
await opencodeInstallPlugins(taskId)
|
||||
} catch {
|
||||
// Error handled by event stream
|
||||
}
|
||||
}, [stream])
|
||||
|
||||
const handleInstallOne = useCallback(
|
||||
async (name: string) => {
|
||||
const taskId = crypto.randomUUID()
|
||||
await stream.start(taskId)
|
||||
try {
|
||||
await opencodeInstallPlugins(taskId, [name])
|
||||
} catch {
|
||||
// Error handled by event stream
|
||||
}
|
||||
},
|
||||
[stream]
|
||||
)
|
||||
|
||||
const handleUninstall = useCallback(async (name: string) => {
|
||||
setUninstalling(name)
|
||||
try {
|
||||
const result = await opencodeUninstallPlugin(name)
|
||||
setSummary(result)
|
||||
} catch (err) {
|
||||
console.error("[OpencodePlugins] Uninstall failed:", err)
|
||||
} finally {
|
||||
setUninstalling(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
onOpenChange(nextOpen)
|
||||
if (!nextOpen) {
|
||||
onCompleted?.()
|
||||
}
|
||||
},
|
||||
[onOpenChange, onCompleted]
|
||||
)
|
||||
|
||||
const missingCount =
|
||||
summary?.plugins.filter((p) => p.status === "missing").length ?? 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto flex-1">
|
||||
{summary && (
|
||||
<div className="text-[11px] text-muted-foreground space-y-0.5">
|
||||
<div>Config: {summary.config_path}</div>
|
||||
<div>Cache: {summary.cache_dir}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !summary ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : summary && summary.plugins.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("declared")}
|
||||
</div>
|
||||
{summary.plugins.map((plugin: PluginInfo) => (
|
||||
<div
|
||||
key={plugin.name}
|
||||
className="flex items-center justify-between rounded-md border bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium truncate">
|
||||
{plugin.declared_spec}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge
|
||||
variant={
|
||||
plugin.status === "installed"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{t(`status.${plugin.status}`)}
|
||||
</Badge>
|
||||
{plugin.installed_version && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
v{plugin.installed_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 ml-2">
|
||||
{plugin.status === "missing" ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={isOperating}
|
||||
onClick={() => handleInstallOne(plugin.name)}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
{t("install")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
disabled={isOperating}
|
||||
onClick={() => handleUninstall(plugin.name)}
|
||||
>
|
||||
{uninstalling === plugin.name ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
{t("uninstall")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">
|
||||
{t("noPlugins")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary && summary.plugins.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isOperating || missingCount === 0}
|
||||
onClick={handleInstallAll}
|
||||
>
|
||||
{stream.status === "running" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{t("installAll")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isOperating}
|
||||
onClick={refresh}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stream.status !== "idle" && (
|
||||
<div className="rounded-md border bg-black/80 text-green-400 p-3 max-h-[200px] overflow-y-auto font-mono text-[11px] leading-relaxed">
|
||||
{stream.logs.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={line.startsWith("ERROR:") ? "text-red-400" : ""}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stream.status === "success" && (
|
||||
<div className="text-xs text-green-600 font-medium">
|
||||
{t("success")}
|
||||
</div>
|
||||
)}
|
||||
{stream.status === "failed" && (
|
||||
<div className="text-xs text-destructive font-medium">
|
||||
{t("failed")}: {stream.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "تكوين مزود واجهة برمجة التطبيقات وبيانات اعتماد Cline. يتم حفظ الإعدادات في ~/.cline/data/."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Konfigurieren Sie den Cline API-Anbieter und die Anmeldedaten. Einstellungen werden in ~/.cline/data/ gespeichert."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Configure Cline API provider and credentials. Settings are saved to ~/.cline/data/."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Configure el proveedor de API y las credenciales de Cline. La configuración se guarda en ~/.cline/data/."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Configurez le fournisseur API et les identifiants Cline. Les paramètres sont enregistrés dans ~/.cline/data/."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Cline API プロバイダーと認証情報を設定します。設定は ~/.cline/data/ に保存されます。"
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Cline API 제공자와 자격 증명을 구성합니다. 설정은 ~/.cline/data/에 저장됩니다."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "Configure o provedor de API e as credenciais do Cline. As configurações são salvas em ~/.cline/data/."
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "配置 Cline API 提供商和凭证。设置将保存到 ~/.cline/data/。"
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
@@ -685,6 +685,21 @@
|
||||
},
|
||||
"cline": {
|
||||
"configDescription": "配置 Cline API 提供商和憑證。設定將儲存到 ~/.cline/data/。"
|
||||
},
|
||||
"opencodePlugins": {
|
||||
"title": "OpenCode Plugins",
|
||||
"declared": "Declared Plugins",
|
||||
"noPlugins": "No plugins declared.",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"installAll": "Install All Missing",
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refresh": "Refresh",
|
||||
"success": "All plugins installed successfully.",
|
||||
"failed": "Installation failed"
|
||||
}
|
||||
},
|
||||
"SettingsPages": {
|
||||
|
||||
Reference in New Issue
Block a user