agent-ecosystem/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
2026-04-17 22:45:19 +03:00

262 lines
8.7 KiB
TypeScript

/**
* PluginDetailDialog — full detail view for a single plugin with install controls.
*/
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
getInstallationSummaryLabel,
getPluginOperationKey,
hasInstallationInScope,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { ExternalLink, Loader2, Mail } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
import { SourceBadge } from '../common/SourceBadge';
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
interface PluginDetailDialogProps {
plugin: EnrichedPlugin | null;
open: boolean;
onClose: () => void;
projectPath: string | null;
}
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project (shared)' },
{ value: 'local', label: 'Local (gitignored)' },
];
export const PluginDetailDialog = ({
plugin,
open,
onClose,
projectPath,
}: PluginDetailDialogProps): React.JSX.Element => {
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin,
}))
);
const [scope, setScope] = useState<InstallScope>('user');
const projectScopeAvailable = Boolean(projectPath);
useEffect(() => {
if (plugin && open) {
fetchPluginReadme(plugin.pluginId);
}
}, [plugin, open, fetchPluginReadme]);
useEffect(() => {
if (open) {
setScope('user');
}
}, [open, plugin?.pluginId]);
useEffect(() => {
if (scope !== 'user' && !projectScopeAvailable) {
setScope('user');
}
}, [projectScopeAvailable, scope]);
const operationKey = plugin
? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined)
: null;
const installProgress = useStore(
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
);
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
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>
<div className="flex shrink-0 items-center gap-1.5">
{installSummaryLabel && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
{installSummaryLabel}
</Badge>
)}
<SourceBadge source={plugin.source} />
</div>
</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">Source</span>
<p className="capitalize text-text">{plugin.source}</p>
</div>
{plugin.version && (
<div>
<span className="text-text-muted">Version</span>
<p className="text-text">{plugin.version}</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}
disabled={opt.value !== 'user' && !projectScopeAvailable}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<InstallButton
state={installProgress}
isInstalled={isInstalledForScope}
section="plugins"
onInstall={() =>
installPlugin({
pluginId: plugin.pluginId,
scope,
...(scope !== 'user' && projectPath ? { projectPath } : {}),
})
}
onUninstall={() =>
uninstallPlugin(
plugin.pluginId,
scope,
scope !== 'user' ? (projectPath ?? undefined) : undefined
)
}
size="default"
errorMessage={installError}
/>
</div>
{/* Links */}
<div className="flex items-center gap-4">
{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>
)}
{plugin.author?.email && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
>
<Mail className="mr-1 size-3.5" />
Contact
</Button>
)}
</div>
{/* 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>
);
};