import PluginInstalledComponent, { PluginInstalledComponentRef, } from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent'; import PluginDetailContent from './PluginDetailContent'; import styles from './plugins.module.css'; import { Button } from '@/components/ui/button'; import { PlusIcon, ChevronDownIcon, UploadIcon, StoreIcon, Download, Power, Github, ChevronLeft, Code, Copy, Check, Bug, } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Card, CardHeader, CardTitle, CardDescription, } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { PluginInstallTaskQueue, usePluginInstallTasks, } from '@/app/home/plugins/components/plugin-install-task'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', SELECT_RELEASE = 'select_release', SELECT_ASSET = 'select_asset', ASK_CONFIRM = 'ask_confirm', INSTALLING = 'installing', ERROR = 'error', } interface GithubRelease { id: number; tag_name: string; name: string; published_at: string; prerelease: boolean; draft: boolean; } interface GithubAsset { id: number; name: string; size: number; download_url: string; content_type: string; } export default function PluginConfigPage() { const [searchParams] = useSearchParams(); const detailId = searchParams.get('id'); // Show plugin detail view when ?id= query param is present if (detailId) { return ; } return ; } function PluginListView() { const { t } = useTranslation(); const navigate = useNavigate(); const { refreshPlugins, pendingPluginInstallAction, setPendingPluginInstallAction, } = useSidebarData(); const { addTask, setSelectedTaskId, registerOnTaskComplete, unregisterOnTaskComplete, } = usePluginInstallTasks(); const [modalOpen, setModalOpen] = useState(false); const [installSource, setInstallSource] = useState('local'); const [installInfo] = useState>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); const [githubReleases, setGithubReleases] = useState([]); const [selectedRelease, setSelectedRelease] = useState( null, ); const [githubAssets, setGithubAssets] = useState([]); const [selectedAsset, setSelectedAsset] = useState(null); const [githubOwner, setGithubOwner] = useState(''); const [githubRepo, setGithubRepo] = useState(''); const [fetchingReleases, setFetchingReleases] = useState(false); const [fetchingAssets, setFetchingAssets] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [pluginSystemStatus, setPluginSystemStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); const fileInputRef = useRef(null); const [debugInfo, setDebugInfo] = useState<{ debug_url: string; plugin_debug_key: string; } | null>(null); const [debugPopoverOpen, setDebugPopoverOpen] = useState(false); const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false); useEffect(() => { const fetchPluginSystemStatus = async () => { try { setStatusLoading(true); const status = await httpClient.getPluginSystemStatus(); setPluginSystemStatus(status); } catch (error) { console.error('Failed to fetch plugin system status:', error); toast.error(t('plugins.failedToGetStatus')); } finally { setStatusLoading(false); } }; fetchPluginSystemStatus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } // Register task completion callback for toast and plugin list refresh useEffect(() => { const onComplete = (_taskId: number, success: boolean) => { if (success) { toast.success(t('plugins.installSuccess')); pluginInstalledRef.current?.refreshPluginList(); refreshPlugins(); } }; registerOnTaskComplete(onComplete); return () => { unregisterOnTaskComplete(onComplete); }; }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]); const pluginInstalledRef = useRef(null); function resetGithubState() { setGithubURL(''); setGithubReleases([]); setSelectedRelease(null); setGithubAssets([]); setSelectedAsset(null); setGithubOwner(''); setGithubRepo(''); setFetchingReleases(false); setFetchingAssets(false); } async function checkExtensionsLimit(): Promise { const maxExtensions = systemInfo.limitation?.max_extensions ?? -1; if (maxExtensions < 0) return true; try { const [pluginsResp, mcpResp] = await Promise.all([ httpClient.getPlugins(), httpClient.getMCPServers(), ]); const total = (pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0); if (total >= maxExtensions) { toast.error( t('limitation.maxExtensionsReached', { max: maxExtensions }), ); return false; } } catch { // If we can't check, let backend handle it } return true; } async function fetchGithubReleases() { if (!githubURL.trim()) { toast.error(t('plugins.enterRepoUrl')); return; } setFetchingReleases(true); setInstallError(null); try { const result = await httpClient.getGithubReleases(githubURL); setGithubReleases(result.releases); setGithubOwner(result.owner); setGithubRepo(result.repo); if (result.releases.length === 0) { toast.warning(t('plugins.noReleasesFound')); } else { setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE); } } catch (error: unknown) { console.error('Failed to fetch GitHub releases:', error); const errorMessage = error instanceof Error ? error.message : String(error); setInstallError(errorMessage || t('plugins.fetchReleasesError')); setPluginInstallStatus(PluginInstallStatus.ERROR); } finally { setFetchingReleases(false); } } async function handleReleaseSelect(release: GithubRelease) { setSelectedRelease(release); setFetchingAssets(true); setInstallError(null); try { const result = await httpClient.getGithubReleaseAssets( githubOwner, githubRepo, release.id, ); setGithubAssets(result.assets); if (result.assets.length === 0) { toast.warning(t('plugins.noAssetsFound')); } else { setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET); } } catch (error: unknown) { console.error('Failed to fetch GitHub release assets:', error); const errorMessage = error instanceof Error ? error.message : String(error); setInstallError(errorMessage || t('plugins.fetchAssetsError')); setPluginInstallStatus(PluginInstallStatus.ERROR); } finally { setFetchingAssets(false); } } function handleAssetSelect(asset: GithubAsset) { setSelectedAsset(asset); setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); } function handleModalConfirm() { if (installSource === 'github' && selectedAsset && selectedRelease) { installPlugin('github', { asset_url: selectedAsset.download_url, owner: githubOwner, repo: githubRepo, release_tag: selectedRelease.tag_name, }); } else { installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any } } function installPlugin( installSource: string, installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any ) { setPluginInstallStatus(PluginInstallStatus.INSTALLING); if (installSource === 'github') { const pluginDisplayName = `${installInfo.owner}/${installInfo.repo}`; const assetSize = selectedAsset?.size; httpClient .installPluginFromGithub( installInfo.asset_url, installInfo.owner, installInfo.repo, installInfo.release_tag, ) .then((resp) => { const taskId = resp.task_id; const taskKey = `github-${taskId}`; addTask({ taskId, pluginName: pluginDisplayName, source: 'github', fileSize: assetSize, }); setSelectedTaskId(taskKey); resetGithubState(); setModalOpen(false); }) .catch((err) => { setInstallError(err.msg); setPluginInstallStatus(PluginInstallStatus.ERROR); }); } else if (installSource === 'local') { const fileName = installInfo.file?.name || 'local plugin'; const fileSize = installInfo.file?.size; httpClient .installPluginFromLocal(installInfo.file) .then((resp) => { const taskId = resp.task_id; const taskKey = `local-${taskId}`; addTask({ taskId, pluginName: fileName, source: 'local', fileSize: fileSize, }); setSelectedTaskId(taskKey); setModalOpen(false); }) .catch((err) => { setInstallError(err.msg); setPluginInstallStatus(PluginInstallStatus.ERROR); }); } } const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; const fileName = file.name.toLowerCase(); return allowedExtensions.some((ext) => fileName.endsWith(ext)); }; const uploadPluginFile = useCallback( async (file: File) => { if (!pluginSystemStatus?.is_enable || !pluginSystemStatus?.is_connected) { toast.error(t('plugins.pluginSystemNotReady')); return; } if (!validateFileType(file)) { toast.error(t('plugins.unsupportedFileType')); return; } if (!(await checkExtensionsLimit())) return; setModalOpen(true); setPluginInstallStatus(PluginInstallStatus.INSTALLING); setInstallError(null); installPlugin('local', { file }); }, [t, pluginSystemStatus, installPlugin], ); const handleFileSelect = useCallback(async () => { if (!(await checkExtensionsLimit())) return; if (fileInputRef.current) { fileInputRef.current.click(); } }, []); const handleFileChange = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { uploadPluginFile(file); } event.target.value = ''; }, [uploadPluginFile], ); const isPluginSystemReady = pluginSystemStatus?.is_enable && pluginSystemStatus?.is_connected; const handleDragOver = useCallback( (event: React.DragEvent) => { event.preventDefault(); if (isPluginSystemReady) { setIsDragOver(true); } }, [isPluginSystemReady], ); const handleDragLeave = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); }, []); const handleDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); if (!isPluginSystemReady) { toast.error(t('plugins.pluginSystemNotReady')); return; } const files = Array.from(event.dataTransfer.files); if (files.length > 0) { uploadPluginFile(files[0]); } }, [uploadPluginFile, isPluginSystemReady, t], ); // Auto-trigger install action from sidebar via shared context useEffect(() => { if (!pendingPluginInstallAction || statusLoading || !isPluginSystemReady) return; // Consume the action immediately const action = pendingPluginInstallAction; setPendingPluginInstallAction(null); if (action === 'local') { // Small delay to ensure file input ref is ready setTimeout(() => fileInputRef.current?.click(), 100); } else if (action === 'github') { setInstallSource('github'); setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); setInstallError(null); resetGithubState(); setModalOpen(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]); const handleShowDebugInfo = async () => { try { const info = await httpClient.getPluginDebugInfo(); setDebugInfo(info); setDebugPopoverOpen(true); } catch (error) { console.error('Failed to fetch debug info:', error); toast.error(t('plugins.failedToGetDebugInfo')); } }; const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => { copyToClipboard(text).catch(() => {}); if (type === 'url') { setCopiedDebugUrl(true); setTimeout(() => setCopiedDebugUrl(false), 2000); } else { setCopiedDebugKey(true); setTimeout(() => setCopiedDebugKey(false), 2000); } }; const renderPluginDisabledState = () => ( {t('plugins.systemDisabled')} {t('plugins.systemDisabledDesc')} ); const renderPluginConnectionErrorState = () => ( {t('plugins.connectionError')} {t('plugins.connectionErrorDesc')} ); const renderLoadingState = () => ( {t('plugins.loadingStatus')} ); if (statusLoading) { return renderLoadingState(); } if (!pluginSystemStatus?.is_enable) { return renderPluginDisabledState(); } if (!pluginSystemStatus?.is_connected) { return renderPluginConnectionErrorState(); } return ( {/* Header bar with debug info, task queue, and install button */} {t('plugins.debugInfo')} {/* Header with icon and title */} {t('plugins.debugInfoTitle')} {/* Debug URL row */} {t('plugins.debugUrl')}: handleCopyDebugInfo(debugInfo?.debug_url || '', 'url') } > {copiedDebugUrl ? ( ) : ( )} {/* Debug Key row */} {t('plugins.debugKey')}: handleCopyDebugInfo( debugInfo?.plugin_debug_key || '', 'key', ) } disabled={!debugInfo?.plugin_debug_key} > {copiedDebugKey ? ( ) : ( )} {!debugInfo?.plugin_debug_key && ( {t('plugins.debugKeyDisabled')} )} {t('plugins.install')} {systemInfo.enable_marketplace && ( { navigate('/home/market'); }} > {t('plugins.goToMarketplace')} )} {t('plugins.uploadLocal')} { if (!(await checkExtensionsLimit())) return; setInstallSource('github'); setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); setInstallError(null); resetGithubState(); setModalOpen(true); }} > {t('plugins.installFromGithub')} {/* Installed plugins grid */} {/* Install plugin dialog (GitHub flow) */} { setModalOpen(open); if (!open) { resetGithubState(); setInstallError(null); } }} > {installSource === 'github' ? ( ) : ( )} {t('plugins.installPlugin')} {/* GitHub Install Flow */} {installSource === 'github' && pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( {t('plugins.enterRepoUrl')} setGithubURL(e.target.value)} className="mb-4" /> {fetchingReleases && ( {t('plugins.fetchingReleases')} )} )} {installSource === 'github' && pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( {t('plugins.selectRelease')} { setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); setGithubReleases([]); }} > {t('plugins.backToRepoUrl')} {githubReleases.map((release) => ( handleReleaseSelect(release)} > {release.name || release.tag_name} {t('plugins.releaseTag', { tag: release.tag_name })}{' '} •{' '} {t('plugins.publishedAt', { date: new Date( release.published_at, ).toLocaleDateString(), })} {release.prerelease && ( {t('plugins.prerelease')} )} ))} {fetchingAssets && ( {t('plugins.loading')} )} )} {installSource === 'github' && pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( {t('plugins.selectAsset')} { setPluginInstallStatus( PluginInstallStatus.SELECT_RELEASE, ); setGithubAssets([]); setSelectedAsset(null); }} > {t('plugins.backToReleases')} {selectedRelease && ( {selectedRelease.name || selectedRelease.tag_name} {selectedRelease.tag_name} )} {githubAssets.map((asset) => ( handleAssetSelect(asset)} > {asset.name} {t('plugins.assetSize', { size: formatFileSize(asset.size), })} ))} )} {/* GitHub Install Confirm */} {installSource === 'github' && pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( {t('plugins.confirmInstall')} { setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET); setSelectedAsset(null); }} > {t('plugins.backToAssets')} {selectedRelease && selectedAsset && ( Repository: {githubOwner}/{githubRepo} Release: {selectedRelease.tag_name} File: {selectedAsset.name} )} )} {/* Installing State */} {pluginInstallStatus === PluginInstallStatus.INSTALLING && ( {t('plugins.installing')} )} {/* Error State */} {pluginInstallStatus === PluginInstallStatus.ERROR && ( {t('plugins.installFailed')} {installError} )} {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && installSource === 'github' && ( <> { setModalOpen(false); resetGithubState(); }} > {t('common.cancel')} {fetchingReleases ? t('plugins.loading') : t('common.confirm')} > )} {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( <> setModalOpen(false)}> {t('common.cancel')} handleModalConfirm()}> {t('common.confirm')} > )} {pluginInstallStatus === PluginInstallStatus.ERROR && ( setModalOpen(false)}> {t('common.close')} )} {isDragOver && ( {t('plugins.dragToUpload')} )} ); }
{t('plugins.systemDisabledDesc')}
{t('plugins.connectionErrorDesc')}
{t('plugins.loadingStatus')}
{t('plugins.debugKeyDisabled')}
{t('plugins.enterRepoUrl')}
{t('plugins.fetchingReleases')}
{t('plugins.selectRelease')}
{t('plugins.loading')}
{t('plugins.selectAsset')}
{t('plugins.confirmInstall')}
{t('plugins.installing')}
{t('plugins.installFailed')}
{installError}
{t('plugins.dragToUpload')}