262 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
};
|