index.tsx471 lines · main
1import Editor, { Monaco, OnMount } from '@monaco-editor/react'
2import { AnimatePresence, motion } from 'framer-motion'
3import type { editor as monacoEditor } from 'monaco-editor'
4import { useCallback, useEffect, useRef, useState } from 'react'
5import { toast } from 'sonner'
6import { KeyboardShortcut } from 'ui'
7import { useSetCommandMenuOpen } from 'ui-patterns'
8
9import { DiffEditor } from '../DiffEditor'
10import ResizableAIWidget from './ResizableAIWidget'
11import { getEditorSelectionParts } from './utils'
12import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
13import { constructHeaders } from '@/data/fetchers'
14import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
15import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
16import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
17
18interface 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
47export 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}