import React from 'react'; import { useShallow } from 'zustand/react/shallow'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { AddPluginDialog } from './AddPluginDialog'; import { RegistryBadge } from './RegistryBadge'; import { toast } from '@/components/ui'; import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout'; import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem'; import { useI18n } from '@/lib/i18n'; import { usePluginsStore, type PluginEntry, type PluginFile, } from '@/stores/usePluginsStore'; import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection'; interface PluginsSidebarProps { onItemSelect?: () => void; onAddClick?: () => void; } type DeleteTarget = | { kind: 'entry'; id: string; label: string } | { kind: 'file'; id: string; label: string } | null; const entryIcon = (entry: PluginEntry): IconName => entry.parsedKind === 'npm' ? 'code-box' : 'folder'; export const PluginsSidebar: React.FC = ({ onItemSelect, onAddClick, }) => { const { t } = useI18n(); const { entries, files, selectedId, setSelected, deleteEntry, deleteFile, loadPlugins } = usePluginsStore( useShallow((s) => ({ entries: s.entries, files: s.files, selectedId: s.selectedId, setSelected: s.setSelected, deleteEntry: s.deleteEntry, deleteFile: s.deleteFile, loadPlugins: s.loadPlugins, })), ); const registryInfo = usePluginsStore((s) => s.registryInfo); const isLoadingRegistry = usePluginsStore((s) => s.isLoadingRegistry); const loadRegistryInfo = usePluginsStore((s) => s.loadRegistryInfo); const updateToLatest = usePluginsStore((s) => s.updateToLatest); const [deleteTarget, setDeleteTarget] = React.useState(null); const [isDeleting, setIsDeleting] = React.useState(false); const [isAddOpen, setIsAddOpen] = React.useState(false); React.useEffect(() => { void loadPlugins(); }, [loadPlugins]); React.useEffect(() => { const handleOpenAdd = () => setIsAddOpen(true); window.addEventListener('openchamber:settings-open-plugin-add', handleOpenAdd); return () => window.removeEventListener('openchamber:settings-open-plugin-add', handleOpenAdd); }, []); const updateCounts = React.useMemo(() => { const counts = { userEntries: 0, projectEntries: 0 }; for (const entry of entries) { const info = registryInfo[entry.spec]; if (info?.kind === 'npm-ok' && info.hasUpdate) { if (entry.scope === 'user') counts.userEntries++; else if (entry.scope === 'project') counts.projectEntries++; } } return counts; }, [entries, registryInfo]); const userEntries = React.useMemo( () => entries.filter((e) => e.scope === 'user'), [entries], ); const projectEntries = React.useMemo( () => entries.filter((e) => e.scope === 'project'), [entries], ); const userFiles = React.useMemo( () => files.filter((f) => f.scope === 'user'), [files], ); const projectFiles = React.useMemo( () => files.filter((f) => f.scope === 'project'), [files], ); const total = entries.length + files.length; const isEmpty = total === 0; const handleAdd = React.useCallback(() => { if (onAddClick) { onAddClick(); } else { setIsAddOpen(true); } }, [onAddClick]); const handleSelect = React.useCallback( (id: string) => { setSelected(id); onItemSelect?.(); }, [onItemSelect, setSelected], ); const handleUpdateToLatest = React.useCallback( async (id: string) => { const entry = entries.find((e) => e.id === id); if (!entry) return; const info = registryInfo[entry.spec]; if (!info || info.kind !== 'npm-ok' || !info.hasUpdate || !info.latestVersion) { return; } const latest = info.latestVersion; const result = await updateToLatest(id); if (result.ok) { toast.success( t('settings.plugins.toast.updatedToLatest', { version: latest }), ); } else { toast.error(t('settings.plugins.toast.refreshFailed')); } }, [entries, registryInfo, t, updateToLatest], ); const handleRefresh = React.useCallback(async () => { toast.info(t('settings.plugins.toast.refreshing')); try { await loadRegistryInfo({ force: true }); } catch { toast.error(t('settings.plugins.toast.refreshFailed')); } }, [loadRegistryInfo, t]); const handleDelete = React.useCallback(async () => { if (!deleteTarget) return; setIsDeleting(true); const result = deleteTarget.kind === 'entry' ? await deleteEntry(deleteTarget.id) : await deleteFile(deleteTarget.id); if (result.ok) { toast.success( result.message || t('settings.plugins.sidebar.toast.deleted', { name: deleteTarget.label }), ); } else { toast.error(t('settings.plugins.sidebar.toast.deleteFailed')); } setDeleteTarget(null); setIsDeleting(false); }, [deleteEntry, deleteFile, deleteTarget, t]); const renderEntry = (entry: PluginEntry) => { const info = registryInfo[entry.spec]; const canUpdate = info?.kind === 'npm-ok' && info.hasUpdate && !!info.latestVersion; const actions: Array<{ label: string; icon?: IconName; destructive?: boolean; onClick: () => void; }> = []; if (canUpdate) { actions.push({ label: t('settings.plugins.sidebar.actions.updateToLatest'), icon: 'arrow-up-s', onClick: () => void handleUpdateToLatest(entry.id), }); } actions.push({ label: t('settings.common.actions.delete'), icon: 'delete-bin', destructive: true, onClick: () => setDeleteTarget({ kind: 'entry', id: entry.id, label: entry.spec }), }); return ( {entry.spec} } metadata={ entry.parsedKind === 'npm' ? t('settings.plugins.sidebar.kind.npm') : t('settings.plugins.sidebar.kind.path') } selected={selectedId === entry.id} onSelect={() => handleSelect(entry.id)} icon={ } actions={actions} /> ); }; const renderFile = (file: PluginFile) => ( handleSelect(file.id)} icon={ } actions={[ { label: t('settings.common.actions.delete'), icon: 'delete-bin', destructive: true, onClick: () => setDeleteTarget({ kind: 'file', id: file.id, label: file.fileName }), }, ]} /> ); const renderGroup = ( label: string, children: React.ReactNode, updateCount = 0, ) => ( <>
{label} {updateCount > 0 && ( {t( updateCount === 1 ? 'settings.plugins.sidebar.group.updatesAvailable_one' : 'settings.plugins.sidebar.group.updatesAvailable_other', { count: updateCount }, )} )}
{children} ); return ( <>

{t('settings.plugins.sidebar.title')}

{t('settings.plugins.sidebar.total', { count: total })}
} > {isEmpty ? (

{t('settings.plugins.sidebar.empty.title')}

{t('settings.plugins.sidebar.empty.description')}

) : ( <> {userEntries.length > 0 && renderGroup( t('settings.plugins.sidebar.group.userEntries'), userEntries.map(renderEntry), updateCounts.userEntries, )} {userFiles.length > 0 && renderGroup( t('settings.plugins.sidebar.group.userFiles'), userFiles.map(renderFile), )} {projectEntries.length > 0 && renderGroup( t('settings.plugins.sidebar.group.projectEntries'), projectEntries.map(renderEntry), updateCounts.projectEntries, )} {projectFiles.length > 0 && renderGroup( t('settings.plugins.sidebar.group.projectFiles'), projectFiles.map(renderFile), )} )}
{ if (!open && !isDeleting) setDeleteTarget(null); }} > {t('settings.plugins.sidebar.deleteDialog.title')} {t('settings.plugins.sidebar.deleteDialog.description', { name: deleteTarget?.label ?? '', })} ); };