agent-ecosystem/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
iliya 126f8e2865 feat: add Extension Store with plugin catalog and MCP registry
Full Extension Store implementation (Phases 0-6):
- Plugin marketplace catalog with ETag caching and search/filter/sort
- MCP server registry with Official + Glama aggregation
- Install/uninstall flows for both plugins and MCP servers via CLI
- Per-tab UI state, skeleton loading, dashed empty states, card polish
- Input validation and security hardening (scope allowlists, env/header
  key regex, projectPath validation, HTTP body size limits)
- 8 test suites covering catalog, install, aggregation, normalizers
2026-03-08 01:00:18 +02:00

188 lines
6.2 KiB
TypeScript

/**
* PluginDetailDialog — full detail view for a single plugin with install controls.
*/
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { api } from '@renderer/api';
import {
getCapabilityLabel,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { ExternalLink, Loader2 } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
interface PluginDetailDialogProps {
plugin: EnrichedPlugin | null;
open: boolean;
onClose: () => void;
}
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
];
export const PluginDetailDialog = ({
plugin,
open,
onClose,
}: PluginDetailDialogProps): React.JSX.Element => {
const fetchPluginReadme = useStore((s) => s.fetchPluginReadme);
const readmes = useStore((s) => s.pluginReadmes);
const readmeLoading = useStore((s) => s.pluginReadmeLoading);
const installProgress = useStore(
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
);
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const [scope, setScope] = useState<InstallScope>('user');
useEffect(() => {
if (plugin && open) {
fetchPluginReadme(plugin.pluginId);
}
}, [plugin, open, fetchPluginReadme]);
if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="truncate">{plugin.name}</DialogTitle>
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div>
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
</div>
</DialogHeader>
{/* Metadata grid */}
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<span className="text-text-muted">Author</span>
<p className="text-text">{plugin.author?.name ?? 'Unknown'}</p>
</div>
<div>
<span className="text-text-muted">Category</span>
<p className="capitalize text-text">{category}</p>
</div>
<div>
<span className="text-text-muted">Capabilities</span>
<div className="mt-0.5 flex flex-wrap gap-1">
{capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="border-purple-500/30 bg-purple-500/10 text-purple-400"
>
{getCapabilityLabel(cap)}
</Badge>
))}
</div>
</div>
<div>
<span className="text-text-muted">Installs</span>
<div className="mt-0.5">
<InstallCountBadge count={plugin.installCount} />
</div>
</div>
</div>
{/* Install controls */}
<div className="flex items-center gap-3 rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="flex flex-1 items-center gap-2">
<Label className="text-xs text-text-muted">Scope:</Label>
<Select value={scope} onValueChange={(v) => setScope(v as InstallScope)}>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
size="default"
/>
</div>
{/* Homepage link */}
{plugin.homepage && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(plugin.homepage!)}
>
<ExternalLink className="mr-1 size-3.5" />
Homepage
</Button>
)}
{/* README */}
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">
{isReadmeLoading && (
<div className="flex items-center gap-2 text-sm text-text-muted">
<Loader2 className="size-4 animate-spin" />
Loading README...
</div>
)}
{!isReadmeLoading && readme && (
<MarkdownViewer content={readme} bare maxHeight="max-h-none" />
)}
{!isReadmeLoading && !readme && (
<p className="text-sm text-text-muted">No README available.</p>
)}
</div>
</DialogContent>
</Dialog>
);
};