|
| 1 | +/** |
| 2 | + * Global Cursor setup via mcp-remote — one `~/.cursor/mcp.json` entry for all repos. |
| 3 | + */ |
| 4 | + |
| 5 | +import { useState } from 'react'; |
| 6 | +import { useTranslation } from 'react-i18next'; |
| 7 | +import { AlertTriangle, Check, Copy, KeyRound, Loader2 } from 'lucide-react'; |
| 8 | +import { Button, Card, CardContent } from '@mcpmux/ui'; |
| 9 | +import { registerApiKeyClient } from '@/lib/api/gateway'; |
| 10 | +import cursorIcon from '@/assets/client-icons/cursor.svg'; |
| 11 | +import { |
| 12 | + buildCursorBridgeMcpJson, |
| 13 | + CURSOR_BRIDGE_CLIENT_NAME, |
| 14 | +} from './cursor-bridge-config.helpers'; |
| 15 | + |
| 16 | +interface CursorBridgeSectionProps { |
| 17 | + gatewayUrl: string; |
| 18 | + gatewayRunning: boolean; |
| 19 | + onRegistered?: () => void; |
| 20 | +} |
| 21 | + |
| 22 | +/** |
| 23 | + * Mint an API key and render a ready-to-paste global Cursor bridge config. |
| 24 | + */ |
| 25 | +export function CursorBridgeSection({ |
| 26 | + gatewayUrl, |
| 27 | + gatewayRunning, |
| 28 | + onRegistered, |
| 29 | +}: CursorBridgeSectionProps) { |
| 30 | + const { t } = useTranslation('clients'); |
| 31 | + const [snippet, setSnippet] = useState<string | null>(null); |
| 32 | + const [isGenerating, setIsGenerating] = useState(false); |
| 33 | + const [error, setError] = useState<string | null>(null); |
| 34 | + const [copied, setCopied] = useState(false); |
| 35 | + |
| 36 | + const handleGenerate = async () => { |
| 37 | + setIsGenerating(true); |
| 38 | + setError(null); |
| 39 | + try { |
| 40 | + const client = await registerApiKeyClient(CURSOR_BRIDGE_CLIENT_NAME, null); |
| 41 | + setSnippet(buildCursorBridgeMcpJson(client.apiKey, gatewayUrl)); |
| 42 | + onRegistered?.(); |
| 43 | + } catch (e) { |
| 44 | + setError(e instanceof Error ? e.message : String(e)); |
| 45 | + } finally { |
| 46 | + setIsGenerating(false); |
| 47 | + } |
| 48 | + }; |
| 49 | + |
| 50 | + const handleCopy = async () => { |
| 51 | + if (!snippet) return; |
| 52 | + try { |
| 53 | + await navigator.clipboard.writeText(snippet); |
| 54 | + setCopied(true); |
| 55 | + setTimeout(() => setCopied(false), 2000); |
| 56 | + } catch { |
| 57 | + // Snippet is selectable as a fallback. |
| 58 | + } |
| 59 | + }; |
| 60 | + |
| 61 | + return ( |
| 62 | + <Card data-testid="cursor-bridge-section"> |
| 63 | + <CardContent className="p-6"> |
| 64 | + <div className="mb-4 flex items-start gap-4"> |
| 65 | + <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-2"> |
| 66 | + <img src={cursorIcon} alt="Cursor" className="h-full w-full object-contain" /> |
| 67 | + </div> |
| 68 | + <div className="min-w-0 flex-1"> |
| 69 | + <h2 className="text-lg font-semibold">{t('cursorBridge.title')}</h2> |
| 70 | + <p className="mt-1 text-sm text-[rgb(var(--muted))]">{t('cursorBridge.description')}</p> |
| 71 | + </div> |
| 72 | + </div> |
| 73 | + |
| 74 | + {!gatewayRunning && ( |
| 75 | + <div className="mb-4 flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs dark:border-amber-700/60 dark:bg-amber-900/20"> |
| 76 | + <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" /> |
| 77 | + <p className="text-amber-800 dark:text-amber-200">{t('cursorBridge.gatewayStopped')}</p> |
| 78 | + </div> |
| 79 | + )} |
| 80 | + |
| 81 | + {snippet ? ( |
| 82 | + <div className="space-y-4"> |
| 83 | + <div> |
| 84 | + <p className="mb-2 text-xs font-medium uppercase tracking-wide text-[rgb(var(--muted))]"> |
| 85 | + {t('cursorBridge.pasteInto')} |
| 86 | + </p> |
| 87 | + <pre |
| 88 | + data-testid="cursor-bridge-snippet" |
| 89 | + className="max-h-72 overflow-auto rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] p-3 font-mono text-xs" |
| 90 | + > |
| 91 | + {snippet} |
| 92 | + </pre> |
| 93 | + </div> |
| 94 | + |
| 95 | + <div className="flex items-start gap-3 rounded-xl border border-amber-300 bg-amber-50 p-3.5 dark:border-amber-700/60 dark:bg-amber-900/20"> |
| 96 | + <AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" /> |
| 97 | + <p className="text-sm text-amber-800 dark:text-amber-200"> |
| 98 | + {t('cursorBridge.keyOnceWarning')} |
| 99 | + </p> |
| 100 | + </div> |
| 101 | + |
| 102 | + <div className="flex flex-wrap gap-2"> |
| 103 | + <Button variant="primary" size="md" onClick={handleCopy} data-testid="cursor-bridge-copy"> |
| 104 | + {copied ? ( |
| 105 | + <> |
| 106 | + <Check className="mr-2 h-4 w-4 text-emerald-500" /> |
| 107 | + {t('cursorBridge.copied')} |
| 108 | + </> |
| 109 | + ) : ( |
| 110 | + <> |
| 111 | + <Copy className="mr-2 h-4 w-4" /> |
| 112 | + {t('cursorBridge.copy')} |
| 113 | + </> |
| 114 | + )} |
| 115 | + </Button> |
| 116 | + <Button |
| 117 | + variant="secondary" |
| 118 | + size="md" |
| 119 | + onClick={() => void handleGenerate()} |
| 120 | + disabled={isGenerating || !gatewayRunning} |
| 121 | + data-testid="cursor-bridge-regenerate" |
| 122 | + > |
| 123 | + {isGenerating ? ( |
| 124 | + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
| 125 | + ) : ( |
| 126 | + <KeyRound className="mr-2 h-4 w-4" /> |
| 127 | + )} |
| 128 | + {t('cursorBridge.regenerate')} |
| 129 | + </Button> |
| 130 | + </div> |
| 131 | + |
| 132 | + <p className="text-xs text-[rgb(var(--muted))]">{t('cursorBridge.fallbackNote')}</p> |
| 133 | + </div> |
| 134 | + ) : ( |
| 135 | + <div className="space-y-3"> |
| 136 | + <p className="text-sm text-[rgb(var(--muted))]">{t('cursorBridge.generateHint')}</p> |
| 137 | + {error && ( |
| 138 | + <p className="text-sm text-red-600 dark:text-red-400" data-testid="cursor-bridge-error"> |
| 139 | + {error} |
| 140 | + </p> |
| 141 | + )} |
| 142 | + <Button |
| 143 | + variant="primary" |
| 144 | + size="md" |
| 145 | + onClick={() => void handleGenerate()} |
| 146 | + disabled={isGenerating || !gatewayRunning} |
| 147 | + data-testid="cursor-bridge-generate" |
| 148 | + > |
| 149 | + {isGenerating ? ( |
| 150 | + <> |
| 151 | + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
| 152 | + {t('cursorBridge.generating')} |
| 153 | + </> |
| 154 | + ) : ( |
| 155 | + <> |
| 156 | + <KeyRound className="mr-2 h-4 w-4" /> |
| 157 | + {t('cursorBridge.generate')} |
| 158 | + </> |
| 159 | + )} |
| 160 | + </Button> |
| 161 | + </div> |
| 162 | + )} |
| 163 | + </CardContent> |
| 164 | + </Card> |
| 165 | + ); |
| 166 | +} |
0 commit comments