277 lines
8.9 KiB
TypeScript
277 lines
8.9 KiB
TypeScript
"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 { randomUUID } from "@/lib/utils"
|
|
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")
|
|
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(() => {
|
|
const container = logEndRef.current?.parentElement
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight
|
|
}
|
|
}, [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 = randomUUID()
|
|
await stream.start(taskId)
|
|
try {
|
|
await opencodeInstallPlugins(taskId)
|
|
} catch {
|
|
// Error handled by event stream
|
|
}
|
|
}, [stream])
|
|
|
|
const handleInstallOne = useCallback(
|
|
async (name: string) => {
|
|
const taskId = 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
|
|
const floatingCount =
|
|
summary?.plugins.filter((p) => p.declared_spec.endsWith("@latest"))
|
|
.length ?? 0
|
|
const hasActionablePlugins = missingCount > 0 || floatingCount > 0
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("opencodePlugins.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("opencodePlugins.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(`opencodePlugins.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("opencodePlugins.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("opencodePlugins.uninstall")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : summary ? (
|
|
<div className="text-xs text-muted-foreground text-center py-4">
|
|
{t("opencodePlugins.noPlugins")}
|
|
</div>
|
|
) : null}
|
|
|
|
{summary && summary.plugins.length > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
size="sm"
|
|
disabled={isOperating || !hasActionablePlugins}
|
|
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" />
|
|
)}
|
|
{missingCount > 0
|
|
? t("opencodePlugins.installAll")
|
|
: t("opencodePlugins.pinVersions")}
|
|
</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("opencodePlugins.refresh")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{stream.status !== "idle" && (
|
|
<div className="rounded-md border bg-muted/50 text-muted-foreground 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-destructive" : ""
|
|
}
|
|
>
|
|
{line}
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
)}
|
|
|
|
{stream.status === "success" && (
|
|
<div className="text-xs text-green-600 font-medium">
|
|
{t("opencodePlugins.success")}
|
|
</div>
|
|
)}
|
|
{stream.status === "failed" && (
|
|
<div className="text-xs text-destructive font-medium">
|
|
{t("opencodePlugins.failed")}: {stream.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|