index.tsx471 lines · main
| 1 | import Editor, { Monaco, OnMount } from '@monaco-editor/react' |
| 2 | import { AnimatePresence, motion } from 'framer-motion' |
| 3 | import type { editor as monacoEditor } from 'monaco-editor' |
| 4 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 5 | import { toast } from 'sonner' |
| 6 | import { KeyboardShortcut } from 'ui' |
| 7 | import { useSetCommandMenuOpen } from 'ui-patterns' |
| 8 | |
| 9 | import { DiffEditor } from '../DiffEditor' |
| 10 | import ResizableAIWidget from './ResizableAIWidget' |
| 11 | import { getEditorSelectionParts } from './utils' |
| 12 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 13 | import { constructHeaders } from '@/data/fetchers' |
| 14 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 15 | import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled' |
| 16 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 17 | |
| 18 | interface AIEditorProps { |
| 19 | id?: string |
| 20 | language?: string |
| 21 | value?: string |
| 22 | defaultValue?: string |
| 23 | aiEndpoint?: string |
| 24 | aiMetadata?: { |
| 25 | projectRef?: string |
| 26 | connectionString?: string | null |
| 27 | orgSlug?: string |
| 28 | language?: string |
| 29 | } |
| 30 | initialPrompt?: string |
| 31 | readOnly?: boolean |
| 32 | autoFocus?: boolean |
| 33 | className?: string |
| 34 | options?: monacoEditor.IStandaloneEditorConstructionOptions |
| 35 | onChange?: (value: string) => void |
| 36 | onClose?: () => void |
| 37 | closeShortcutEnabled?: boolean |
| 38 | openAIAssistantShortcutEnabled?: boolean |
| 39 | executeQuery?: () => void |
| 40 | onMount?: (editor: monacoEditor.IStandaloneCodeEditor, monaco: Monaco) => void |
| 41 | } |
| 42 | |
| 43 | // [Joshen] This has overlap with components/interfaces/SQLEditor/MonacoEditor |
| 44 | // Can we try to de-dupe accordingly? Perhaps the SQL Editor could use this AIEditor |
| 45 | // We have a tendency to create multiple versions of the monaco editor like RLSCodeEditor |
| 46 | // so hoping to prevent that from snowballing |
| 47 | export const AIEditor = ({ |
| 48 | language = 'javascript', |
| 49 | value, |
| 50 | defaultValue = '', |
| 51 | aiEndpoint, |
| 52 | aiMetadata, |
| 53 | initialPrompt, |
| 54 | readOnly = false, |
| 55 | autoFocus = false, |
| 56 | className = '', |
| 57 | options = {}, |
| 58 | onChange, |
| 59 | onClose, |
| 60 | closeShortcutEnabled = true, |
| 61 | openAIAssistantShortcutEnabled = true, |
| 62 | executeQuery, |
| 63 | onMount, |
| 64 | }: AIEditorProps) => { |
| 65 | const { toggleSidebar } = useSidebarManagerSnapshot() |
| 66 | const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null) |
| 67 | const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null) |
| 68 | const monacoRef = useRef<Monaco | null>(null) |
| 69 | const closeActionDisposableRef = useRef<{ dispose: () => void } | null>(null) |
| 70 | |
| 71 | const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN) |
| 72 | const setCommandMenuOpen = useSetCommandMenuOpen() |
| 73 | |
| 74 | const executeQueryRef = useRef(executeQuery) |
| 75 | executeQueryRef.current = executeQuery |
| 76 | |
| 77 | const commandMenuHotkeyEnabledRef = useRef(isCommandMenuHotkeyEnabled) |
| 78 | commandMenuHotkeyEnabledRef.current = isCommandMenuHotkeyEnabled |
| 79 | |
| 80 | const setCommandMenuOpenRef = useRef(setCommandMenuOpen) |
| 81 | setCommandMenuOpenRef.current = setCommandMenuOpen |
| 82 | |
| 83 | const [currentValue, setCurrentValue] = useState(value || defaultValue) |
| 84 | const [isDiffMode, setIsDiffMode] = useState(false) |
| 85 | const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false) |
| 86 | const [diffValue, setDiffValue] = useState({ original: '', modified: '' }) |
| 87 | const [promptState, setPromptState] = useState({ |
| 88 | isOpen: Boolean(initialPrompt), |
| 89 | selection: '', |
| 90 | beforeSelection: '', |
| 91 | afterSelection: '', |
| 92 | startLineNumber: 0, |
| 93 | endLineNumber: 0, |
| 94 | }) |
| 95 | const [promptInput, setPromptInput] = useState(initialPrompt || '') |
| 96 | |
| 97 | const [isCompletionLoading, setIsCompletionLoading] = useState(false) |
| 98 | |
| 99 | const complete = useCallback( |
| 100 | async ( |
| 101 | _prompt: string, |
| 102 | options?: { |
| 103 | headers?: Record<string, string> |
| 104 | body?: { completionMetadata?: any } |
| 105 | } |
| 106 | ) => { |
| 107 | try { |
| 108 | if (!aiEndpoint) throw new Error('AI endpoint is not configured') |
| 109 | setIsCompletionLoading(true) |
| 110 | |
| 111 | const response = await fetch(aiEndpoint, { |
| 112 | method: 'POST', |
| 113 | headers: { |
| 114 | 'Content-Type': 'application/json', |
| 115 | ...(options?.headers ?? {}), |
| 116 | }, |
| 117 | body: JSON.stringify({ |
| 118 | ...(aiMetadata ?? {}), |
| 119 | ...(options?.body ?? {}), |
| 120 | }), |
| 121 | }) |
| 122 | |
| 123 | if (!response.ok) { |
| 124 | const errorText = await response.text() |
| 125 | throw new Error(errorText || 'Failed to generate completion') |
| 126 | } |
| 127 | |
| 128 | const text: string = await response.json() |
| 129 | |
| 130 | const meta = options?.body?.completionMetadata ?? {} |
| 131 | const beforeSelection: string = meta.textBeforeCursor ?? '' |
| 132 | const afterSelection: string = meta.textAfterCursor ?? '' |
| 133 | const selection: string = meta.selection ?? '' |
| 134 | |
| 135 | const original = beforeSelection + selection + afterSelection |
| 136 | const modified = beforeSelection + text + afterSelection |
| 137 | |
| 138 | setDiffValue({ original, modified }) |
| 139 | setIsDiffMode(true) |
| 140 | } catch (error: any) { |
| 141 | toast.error(`Failed to generate: ${error?.message ?? 'Unknown error'}`) |
| 142 | } finally { |
| 143 | setIsCompletionLoading(false) |
| 144 | } |
| 145 | }, |
| 146 | [aiEndpoint, aiMetadata] |
| 147 | ) |
| 148 | |
| 149 | const handleReset = useCallback(() => { |
| 150 | setIsDiffMode(false) |
| 151 | setPromptState((prev) => ({ ...prev, isOpen: false })) |
| 152 | setPromptInput('') |
| 153 | editorRef.current?.focus() |
| 154 | }, []) |
| 155 | |
| 156 | const handleAcceptDiff = useCallback(() => { |
| 157 | if (diffValue.modified) { |
| 158 | const newValue = diffValue.modified |
| 159 | setCurrentValue(newValue) |
| 160 | onChange?.(newValue) |
| 161 | handleReset() |
| 162 | } |
| 163 | }, [diffValue.modified, onChange, handleReset]) |
| 164 | |
| 165 | const handleRejectDiff = () => { |
| 166 | handleReset() |
| 167 | } |
| 168 | |
| 169 | const refreshCloseAction = useCallback(() => { |
| 170 | closeActionDisposableRef.current?.dispose() |
| 171 | closeActionDisposableRef.current = null |
| 172 | |
| 173 | const editor = editorRef.current |
| 174 | const monaco = monacoRef.current |
| 175 | |
| 176 | if (!editor || !monaco || !onClose || !closeShortcutEnabled) return |
| 177 | |
| 178 | const action = editor.addAction({ |
| 179 | id: 'close-editor', |
| 180 | label: 'Close editor', |
| 181 | keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyE], |
| 182 | contextMenuGroupId: 'operation', |
| 183 | contextMenuOrder: 0, |
| 184 | run: onClose, |
| 185 | }) |
| 186 | |
| 187 | closeActionDisposableRef.current = action ?? null |
| 188 | }, [closeShortcutEnabled, onClose]) |
| 189 | |
| 190 | const handleEditorOnMount: OnMount = ( |
| 191 | editor: monacoEditor.IStandaloneCodeEditor, |
| 192 | monaco: Monaco |
| 193 | ) => { |
| 194 | editorRef.current = editor |
| 195 | monacoRef.current = monaco |
| 196 | onMount?.(editor, monaco) |
| 197 | // Set prompt state to open if promptInput exists |
| 198 | if (promptInput) { |
| 199 | const model = editor.getModel() |
| 200 | if (model) { |
| 201 | const lineCount = model.getLineCount() |
| 202 | setPromptState({ |
| 203 | isOpen: true, |
| 204 | selection: model.getValue(), |
| 205 | beforeSelection: '', |
| 206 | afterSelection: '', |
| 207 | startLineNumber: 1, |
| 208 | endLineNumber: lineCount, |
| 209 | }) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // [Joshen] Opting to ignore "Cannot find module" errors here as users are getting |
| 214 | // confused with the error highlighting when importing external modules |
| 215 | monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ |
| 216 | diagnosticCodesToIgnore: [2792], |
| 217 | }) |
| 218 | |
| 219 | if (language === 'javascript' || language === 'typescript') { |
| 220 | // The Deno libs are loaded as a raw text via raw-loader in next.config.ts. They're passed as raw text to the |
| 221 | // Monaco editor. |
| 222 | import('@/public/deno/edge-runtime.d.ts' as string) |
| 223 | .then((module) => { |
| 224 | monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) |
| 225 | }) |
| 226 | .catch((error) => { |
| 227 | console.error('Failed to load Deno edge-runtime typings:', error) |
| 228 | }) |
| 229 | import('@/public/deno/lib.deno.d.ts' as string) |
| 230 | .then((module) => { |
| 231 | monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default) |
| 232 | }) |
| 233 | .catch((error) => { |
| 234 | console.error('Failed to load Deno lib typings:', error) |
| 235 | }) |
| 236 | } |
| 237 | |
| 238 | if (!!executeQueryRef.current) { |
| 239 | editor.addAction({ |
| 240 | id: 'run-query', |
| 241 | label: 'Run Query', |
| 242 | keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], |
| 243 | contextMenuGroupId: 'operation', |
| 244 | contextMenuOrder: 0, |
| 245 | run: () => executeQueryRef.current?.(), |
| 246 | }) |
| 247 | } |
| 248 | |
| 249 | refreshCloseAction() |
| 250 | |
| 251 | // Add AI Assistant toggle keybinding (Cmd+I) |
| 252 | if (openAIAssistantShortcutEnabled) { |
| 253 | editor.addAction({ |
| 254 | id: 'toggle-ai-assistant', |
| 255 | label: 'Toggle AI Assistant', |
| 256 | keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], |
| 257 | run: () => { |
| 258 | toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) |
| 259 | }, |
| 260 | }) |
| 261 | } |
| 262 | |
| 263 | editor.addAction({ |
| 264 | id: 'generate-ai', |
| 265 | label: 'Generate with AI', |
| 266 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK], |
| 267 | run: () => { |
| 268 | const selectionParts = getEditorSelectionParts(editor) |
| 269 | if (!selectionParts) return |
| 270 | setPromptState({ isOpen: true, ...selectionParts }) |
| 271 | }, |
| 272 | }) |
| 273 | |
| 274 | // Monaco claims Cmd+K as a chord prefix, which swallows the global command |
| 275 | // menu shortcut while the editor is focused. Intercept it here and open the |
| 276 | // command menu directly so it works the same inside and outside the editor. |
| 277 | editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => { |
| 278 | if (commandMenuHotkeyEnabledRef.current) { |
| 279 | setCommandMenuOpenRef.current(true) |
| 280 | } |
| 281 | }) |
| 282 | |
| 283 | if (autoFocus) { |
| 284 | if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 }) |
| 285 | editor.focus() |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | const handlePrompt = async ( |
| 290 | prompt: string, |
| 291 | context: { |
| 292 | beforeSelection: string |
| 293 | selection: string |
| 294 | afterSelection: string |
| 295 | } |
| 296 | ) => { |
| 297 | try { |
| 298 | setPromptState((prev) => ({ |
| 299 | ...prev, |
| 300 | selection: context.selection, |
| 301 | beforeSelection: context.beforeSelection, |
| 302 | afterSelection: context.afterSelection, |
| 303 | })) |
| 304 | |
| 305 | const headerData = await constructHeaders() |
| 306 | const authorizationHeader = headerData.get('Authorization') |
| 307 | |
| 308 | await complete(prompt, { |
| 309 | ...(authorizationHeader ? { headers: { Authorization: authorizationHeader } } : undefined), |
| 310 | body: { |
| 311 | ...aiMetadata, |
| 312 | completionMetadata: { |
| 313 | textBeforeCursor: context.beforeSelection, |
| 314 | textAfterCursor: context.afterSelection, |
| 315 | language, |
| 316 | prompt, |
| 317 | selection: context.selection, |
| 318 | }, |
| 319 | }, |
| 320 | }) |
| 321 | } catch (error) { |
| 322 | setPromptState((prev) => ({ ...prev, isOpen: false })) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | const defaultOptions: monacoEditor.IStandaloneEditorConstructionOptions = { |
| 327 | tabSize: 2, |
| 328 | fontSize: 13, |
| 329 | readOnly, |
| 330 | minimap: { enabled: false }, |
| 331 | wordWrap: 'on', |
| 332 | lineNumbers: 'on', |
| 333 | folding: false, |
| 334 | padding: { top: 4 }, |
| 335 | lineNumbersMinChars: 3, |
| 336 | ...options, |
| 337 | } |
| 338 | |
| 339 | useEffect(() => { |
| 340 | setCurrentValue(value || defaultValue) |
| 341 | }, [value, defaultValue]) |
| 342 | |
| 343 | useEffect(() => { |
| 344 | if (initialPrompt) { |
| 345 | setPromptInput(initialPrompt) |
| 346 | setPromptState({ |
| 347 | isOpen: Boolean(initialPrompt), |
| 348 | selection: '', |
| 349 | beforeSelection: '', |
| 350 | afterSelection: '', |
| 351 | startLineNumber: 0, |
| 352 | endLineNumber: 0, |
| 353 | }) |
| 354 | } |
| 355 | }, [initialPrompt]) |
| 356 | |
| 357 | useEffect(() => { |
| 358 | if (!isDiffMode) { |
| 359 | setIsDiffEditorMounted(false) |
| 360 | } |
| 361 | }, [isDiffMode]) |
| 362 | |
| 363 | useEffect(() => { |
| 364 | const handleKeyboard = (event: KeyboardEvent) => { |
| 365 | if (event.key === 'Escape') { |
| 366 | handleReset() |
| 367 | } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && isDiffMode) { |
| 368 | event.preventDefault() |
| 369 | handleAcceptDiff() |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | window.addEventListener('keydown', handleKeyboard) |
| 374 | return () => window.removeEventListener('keydown', handleKeyboard) |
| 375 | }, [isDiffMode, handleAcceptDiff, handleReset]) |
| 376 | |
| 377 | return ( |
| 378 | <div className="flex-1 overflow-hidden flex flex-col h-full relative"> |
| 379 | {isDiffMode ? ( |
| 380 | <div className="w-full h-full"> |
| 381 | <DiffEditor |
| 382 | language={language} |
| 383 | original={diffValue.original} |
| 384 | modified={diffValue.modified} |
| 385 | onMount={(editor: monacoEditor.IStandaloneDiffEditor) => { |
| 386 | diffEditorRef.current = editor |
| 387 | setIsDiffEditorMounted(true) |
| 388 | }} |
| 389 | /> |
| 390 | {isDiffEditorMounted && ( |
| 391 | <ResizableAIWidget |
| 392 | editor={diffEditorRef.current!} |
| 393 | id="ask-ai-diff" |
| 394 | value={promptInput} |
| 395 | onChange={setPromptInput} |
| 396 | onSubmit={(prompt: string) => { |
| 397 | handlePrompt(prompt, { |
| 398 | beforeSelection: promptState.beforeSelection, |
| 399 | selection: promptState.selection || diffValue.modified, |
| 400 | afterSelection: promptState.afterSelection, |
| 401 | }) |
| 402 | }} |
| 403 | onAccept={handleAcceptDiff} |
| 404 | onReject={handleRejectDiff} |
| 405 | onCancel={handleReset} |
| 406 | isDiffVisible={true} |
| 407 | isLoading={isCompletionLoading} |
| 408 | startLineNumber={Math.max(0, promptState.startLineNumber)} |
| 409 | endLineNumber={promptState.endLineNumber} |
| 410 | /> |
| 411 | )} |
| 412 | </div> |
| 413 | ) : ( |
| 414 | <div className="w-full h-full relative"> |
| 415 | {/* [Joshen] Refactor: Use CodeEditor.tsx instead, reduce duplicate declaration of Editor */} |
| 416 | <Editor |
| 417 | theme="briven" |
| 418 | language={language} |
| 419 | value={currentValue} |
| 420 | options={defaultOptions} |
| 421 | onChange={(value: string | undefined) => { |
| 422 | const newValue = value || '' |
| 423 | setCurrentValue(newValue) |
| 424 | onChange?.(newValue) |
| 425 | }} |
| 426 | onMount={handleEditorOnMount} |
| 427 | className={className} |
| 428 | /> |
| 429 | {promptState.isOpen && editorRef.current && ( |
| 430 | <ResizableAIWidget |
| 431 | editor={editorRef.current} |
| 432 | id="ask-ai" |
| 433 | value={promptInput} |
| 434 | onChange={setPromptInput} |
| 435 | onSubmit={(prompt: string) => { |
| 436 | handlePrompt(prompt, { |
| 437 | beforeSelection: promptState.beforeSelection, |
| 438 | selection: promptState.selection, |
| 439 | afterSelection: promptState.afterSelection, |
| 440 | }) |
| 441 | }} |
| 442 | onCancel={handleReset} |
| 443 | isDiffVisible={false} |
| 444 | isLoading={isCompletionLoading} |
| 445 | startLineNumber={Math.max(0, promptState.startLineNumber)} |
| 446 | endLineNumber={promptState.endLineNumber} |
| 447 | /> |
| 448 | )} |
| 449 | <AnimatePresence> |
| 450 | {!promptState.isOpen && !currentValue && aiEndpoint && ( |
| 451 | <motion.p |
| 452 | initial={{ y: 5, opacity: 0 }} |
| 453 | animate={{ y: 0, opacity: 1 }} |
| 454 | exit={{ y: 5, opacity: 0 }} |
| 455 | className="text-foreground-lighter absolute bottom-4 left-4 z-10 font-mono text-xs flex items-center gap-1" |
| 456 | > |
| 457 | Hit{' '} |
| 458 | <KeyboardShortcut |
| 459 | keys={['Meta', 'Shift', 'k']} |
| 460 | variant="inline" |
| 461 | className="text-xs text-foreground-lighter" |
| 462 | />{' '} |
| 463 | to edit with the Assistant |
| 464 | </motion.p> |
| 465 | )} |
| 466 | </AnimatePresence> |
| 467 | </div> |
| 468 | )} |
| 469 | </div> |
| 470 | ) |
| 471 | } |