import React from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { selectMcpServersForDirectory, useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore'; import { useShallow } from 'zustand/react/shallow'; import { useMcpStore } from '@/stores/useMcpStore'; import { useSettingsDirectory } from '@/hooks/useSettingsDirectory'; import { isMobileDeviceViaCSS } from '@/lib/device'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { useI18n } from '@/lib/i18n'; import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection'; interface McpSidebarProps { onItemSelect?: () => void; } // ---- Status dot ---- type StatusTone = 'success' | 'error' | 'warning' | 'idle'; const statusToneFromMcp = (status: string | undefined): StatusTone => { switch (status) { case 'connected': return 'success'; case 'failed': return 'error'; case 'needs_auth': case 'needs_client_registration': return 'warning'; default: return 'idle'; } }; const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, enabled }) => { if (!enabled) { return ( ); } const classes: Record = { success: 'bg-[var(--status-success)]', error: 'bg-[var(--status-error)]', warning: 'bg-[var(--status-warning)]', idle: 'bg-muted-foreground/40', }; return ( ); }; export const McpSidebar: React.FC = ({ onItemSelect }) => { const { t } = useI18n(); const bgClass = 'bg-background'; const { selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } = useMcpConfigStore(useShallow((s) => ({ selectedMcpName: s.selectedMcpName, setSelectedMcp: s.setSelectedMcp, setMcpDraft: s.setMcpDraft, loadMcpConfigs: s.loadMcpConfigs, deleteMcp: s.deleteMcp, }))); // Settings browses whichever project its own selector points at; the app // stays where it is. const settingsDirectory = useSettingsDirectory(); const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, settingsDirectory)); const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(settingsDirectory)); const refreshStatus = useMcpStore((state) => state.refresh); const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory); const [deleteTarget, setDeleteTarget] = React.useState(null); const [isDeleting, setIsDeleting] = React.useState(false); const [openMenuMcp, setOpenMenuMcp] = React.useState(null); const [rightClickMenuMcp, setRightClickMenuMcp] = React.useState(null); const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false); const projectServers = React.useMemo( () => mcpServers.filter((server) => server.scope === 'project'), [mcpServers] ); const userServers = React.useMemo( () => mcpServers.filter((server) => server.scope !== 'project'), [mcpServers] ); React.useEffect(() => { void loadMcpConfigs({ directory: settingsDirectory }); }, [loadMcpConfigs, settingsDirectory]); const handleRefresh = React.useCallback(() => { if (isRefreshingStatus) return; setIsRefreshingStatus(true); const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500)); Promise.all([ refreshStatus({ directory: settingsDirectory, silent: true }), minSpinPromise, ]).then(() => { const error = getErrorForDirectory(settingsDirectory); if (error) { toast.error(error); } }).finally(() => { setIsRefreshingStatus(false); }); }, [getErrorForDirectory, isRefreshingStatus, refreshStatus, settingsDirectory]); const handleCreateNew = () => { const baseName = 'new-mcp-server'; let newName = baseName; let counter = 1; while (mcpServers.some((s) => s.name === newName)) { newName = `${baseName}-${counter}`; counter++; } const draft: McpDraft = { name: newName, scope: 'user', type: 'local', command: [], url: '', environment: [], headers: [], oauthEnabled: true, oauthClientId: '', oauthClientSecret: '', oauthScope: '', oauthRedirectUri: '', timeout: '', enabled: true, }; setMcpDraft(draft); setSelectedMcp(newName); onItemSelect?.(); }; const handleDelete = async () => { if (!deleteTarget) return; setIsDeleting(true); const result = await deleteMcp(deleteTarget.name, settingsDirectory); if (result.ok) { if (result.reloadFailed) { toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, { description: result.warning || t('settings.mcp.sidebar.toast.refreshListIfStale'), }); } else { toast.success(result.message || t('settings.mcp.sidebar.toast.serverDeleted', { name: deleteTarget.name })); } } else { toast.error(t('settings.mcp.sidebar.toast.deleteFailed')); } setDeleteTarget(null); setIsDeleting(false); }; const renderMcpMenuItems = (server: McpServerConfig, Item: React.ElementType) => ( { e.stopPropagation(); setDeleteTarget(server); }} className="text-destructive focus:text-destructive" > {t('settings.common.actions.delete')} ); return (

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

{t('settings.mcp.sidebar.total', { count: mcpServers.length })}
{/* List */} {mcpServers.length === 0 ? (

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

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

) : ( <> {projectServers.length > 0 && ( <>
{t('settings.mcp.sidebar.group.projectServers')}
{projectServers.map((server) => { const runtimeStatus = mcpStatus[server.name]; const tone = statusToneFromMcp(runtimeStatus?.status); const isSelected = selectedMcpName === server.name; const isMobile = isMobileDeviceViaCSS(); return ( setRightClickMenuMcp(open ? server.name : null)}> { e.preventDefault(); setRightClickMenuMcp(server.name); } : undefined} />}> { if (open) setRightClickMenuMcp(null); setOpenMenuMcp(open ? server.name : null); }}> {renderMcpMenuItems(server, DropdownMenuItem)} {renderMcpMenuItems(server, ContextMenuItem)} ); })} )} {userServers.length > 0 && ( <>
{t('settings.mcp.sidebar.group.userServers')}
{userServers.map((server) => { const runtimeStatus = mcpStatus[server.name]; const tone = statusToneFromMcp(runtimeStatus?.status); const isSelected = selectedMcpName === server.name; const isMobile = isMobileDeviceViaCSS(); return ( setRightClickMenuMcp(open ? server.name : null)}> { e.preventDefault(); setRightClickMenuMcp(server.name); } : undefined} />}> { if (open) setRightClickMenuMcp(null); setOpenMenuMcp(open ? server.name : null); }}> {renderMcpMenuItems(server, DropdownMenuItem)} {renderMcpMenuItems(server, ContextMenuItem)} ); })} )} )}
{/* Delete confirm dialog */} { if (!open && !isDeleting) setDeleteTarget(null); }} > {t('settings.mcp.sidebar.deleteDialog.title')} {t('settings.mcp.sidebar.deleteDialog.descriptionPrefix', { name: deleteTarget?.name || '' })}{' '} opencode.json.
); }; // Re-export for easy sidebar icon usage import { Icon } from "@/components/icon/Icon";