feat(frontend): add OpencodePluginsModal component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-12 10:40:50 +08:00
parent 185ba39269
commit 6da3a6cc34
11 changed files with 414 additions and 0 deletions

View 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>
)
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {