| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- import Editor, { Monaco, OnMount } from '@monaco-editor/react'
- import { AnimatePresence, motion } from 'framer-motion'
- import type { editor as monacoEditor } from 'monaco-editor'
- import { useCallback, useEffect, useRef, useState } from 'react'
- import { toast } from 'sonner'
- import { KeyboardShortcut } from 'ui'
- import { useSetCommandMenuOpen } from 'ui-patterns'
- import { DiffEditor } from '../DiffEditor'
- import ResizableAIWidget from './ResizableAIWidget'
- import { getEditorSelectionParts } from './utils'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import { constructHeaders } from '@/data/fetchers'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- interface AIEditorProps {
- id?: string
- language?: string
- value?: string
- defaultValue?: string
- aiEndpoint?: string
- aiMetadata?: {
- projectRef?: string
- connectionString?: string | null
- orgSlug?: string
- language?: string
- }
- initialPrompt?: string
- readOnly?: boolean
- autoFocus?: boolean
- className?: string
- options?: monacoEditor.IStandaloneEditorConstructionOptions
- onChange?: (value: string) => void
- onClose?: () => void
- closeShortcutEnabled?: boolean
- openAIAssistantShortcutEnabled?: boolean
- executeQuery?: () => void
- onMount?: (editor: monacoEditor.IStandaloneCodeEditor, monaco: Monaco) => void
- }
- // [Joshen] This has overlap with components/interfaces/SQLEditor/MonacoEditor
- // Can we try to de-dupe accordingly? Perhaps the SQL Editor could use this AIEditor
- // We have a tendency to create multiple versions of the monaco editor like RLSCodeEditor
- // so hoping to prevent that from snowballing
- export const AIEditor = ({
- language = 'javascript',
- value,
- defaultValue = '',
- aiEndpoint,
- aiMetadata,
- initialPrompt,
- readOnly = false,
- autoFocus = false,
- className = '',
- options = {},
- onChange,
- onClose,
- closeShortcutEnabled = true,
- openAIAssistantShortcutEnabled = true,
- executeQuery,
- onMount,
- }: AIEditorProps) => {
- const { toggleSidebar } = useSidebarManagerSnapshot()
- const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null)
- const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null)
- const monacoRef = useRef<Monaco | null>(null)
- const closeActionDisposableRef = useRef<{ dispose: () => void } | null>(null)
- const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
- const setCommandMenuOpen = useSetCommandMenuOpen()
- const executeQueryRef = useRef(executeQuery)
- executeQueryRef.current = executeQuery
- const commandMenuHotkeyEnabledRef = useRef(isCommandMenuHotkeyEnabled)
- commandMenuHotkeyEnabledRef.current = isCommandMenuHotkeyEnabled
- const setCommandMenuOpenRef = useRef(setCommandMenuOpen)
- setCommandMenuOpenRef.current = setCommandMenuOpen
- const [currentValue, setCurrentValue] = useState(value || defaultValue)
- const [isDiffMode, setIsDiffMode] = useState(false)
- const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
- const [diffValue, setDiffValue] = useState({ original: '', modified: '' })
- const [promptState, setPromptState] = useState({
- isOpen: Boolean(initialPrompt),
- selection: '',
- beforeSelection: '',
- afterSelection: '',
- startLineNumber: 0,
- endLineNumber: 0,
- })
- const [promptInput, setPromptInput] = useState(initialPrompt || '')
- const [isCompletionLoading, setIsCompletionLoading] = useState(false)
- const complete = useCallback(
- async (
- _prompt: string,
- options?: {
- headers?: Record<string, string>
- body?: { completionMetadata?: any }
- }
- ) => {
- try {
- if (!aiEndpoint) throw new Error('AI endpoint is not configured')
- setIsCompletionLoading(true)
- const response = await fetch(aiEndpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(options?.headers ?? {}),
- },
- body: JSON.stringify({
- ...(aiMetadata ?? {}),
- ...(options?.body ?? {}),
- }),
- })
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(errorText || 'Failed to generate completion')
- }
- const text: string = await response.json()
- const meta = options?.body?.completionMetadata ?? {}
- const beforeSelection: string = meta.textBeforeCursor ?? ''
- const afterSelection: string = meta.textAfterCursor ?? ''
- const selection: string = meta.selection ?? ''
- const original = beforeSelection + selection + afterSelection
- const modified = beforeSelection + text + afterSelection
- setDiffValue({ original, modified })
- setIsDiffMode(true)
- } catch (error: any) {
- toast.error(`Failed to generate: ${error?.message ?? 'Unknown error'}`)
- } finally {
- setIsCompletionLoading(false)
- }
- },
- [aiEndpoint, aiMetadata]
- )
- const handleReset = useCallback(() => {
- setIsDiffMode(false)
- setPromptState((prev) => ({ ...prev, isOpen: false }))
- setPromptInput('')
- editorRef.current?.focus()
- }, [])
- const handleAcceptDiff = useCallback(() => {
- if (diffValue.modified) {
- const newValue = diffValue.modified
- setCurrentValue(newValue)
- onChange?.(newValue)
- handleReset()
- }
- }, [diffValue.modified, onChange, handleReset])
- const handleRejectDiff = () => {
- handleReset()
- }
- const refreshCloseAction = useCallback(() => {
- closeActionDisposableRef.current?.dispose()
- closeActionDisposableRef.current = null
- const editor = editorRef.current
- const monaco = monacoRef.current
- if (!editor || !monaco || !onClose || !closeShortcutEnabled) return
- const action = editor.addAction({
- id: 'close-editor',
- label: 'Close editor',
- keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyE],
- contextMenuGroupId: 'operation',
- contextMenuOrder: 0,
- run: onClose,
- })
- closeActionDisposableRef.current = action ?? null
- }, [closeShortcutEnabled, onClose])
- const handleEditorOnMount: OnMount = (
- editor: monacoEditor.IStandaloneCodeEditor,
- monaco: Monaco
- ) => {
- editorRef.current = editor
- monacoRef.current = monaco
- onMount?.(editor, monaco)
- // Set prompt state to open if promptInput exists
- if (promptInput) {
- const model = editor.getModel()
- if (model) {
- const lineCount = model.getLineCount()
- setPromptState({
- isOpen: true,
- selection: model.getValue(),
- beforeSelection: '',
- afterSelection: '',
- startLineNumber: 1,
- endLineNumber: lineCount,
- })
- }
- }
- // [Joshen] Opting to ignore "Cannot find module" errors here as users are getting
- // confused with the error highlighting when importing external modules
- monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
- diagnosticCodesToIgnore: [2792],
- })
- if (language === 'javascript' || language === 'typescript') {
- // The Deno libs are loaded as a raw text via raw-loader in next.config.ts. They're passed as raw text to the
- // Monaco editor.
- import('@/public/deno/edge-runtime.d.ts' as string)
- .then((module) => {
- monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default)
- })
- .catch((error) => {
- console.error('Failed to load Deno edge-runtime typings:', error)
- })
- import('@/public/deno/lib.deno.d.ts' as string)
- .then((module) => {
- monaco.languages.typescript.typescriptDefaults.addExtraLib(module.default)
- })
- .catch((error) => {
- console.error('Failed to load Deno lib typings:', error)
- })
- }
- if (!!executeQueryRef.current) {
- editor.addAction({
- id: 'run-query',
- label: 'Run Query',
- keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
- contextMenuGroupId: 'operation',
- contextMenuOrder: 0,
- run: () => executeQueryRef.current?.(),
- })
- }
- refreshCloseAction()
- // Add AI Assistant toggle keybinding (Cmd+I)
- if (openAIAssistantShortcutEnabled) {
- editor.addAction({
- id: 'toggle-ai-assistant',
- label: 'Toggle AI Assistant',
- keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI],
- run: () => {
- toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- },
- })
- }
- editor.addAction({
- id: 'generate-ai',
- label: 'Generate with AI',
- keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK],
- run: () => {
- const selectionParts = getEditorSelectionParts(editor)
- if (!selectionParts) return
- setPromptState({ isOpen: true, ...selectionParts })
- },
- })
- // Monaco claims Cmd+K as a chord prefix, which swallows the global command
- // menu shortcut while the editor is focused. Intercept it here and open the
- // command menu directly so it works the same inside and outside the editor.
- editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
- if (commandMenuHotkeyEnabledRef.current) {
- setCommandMenuOpenRef.current(true)
- }
- })
- if (autoFocus) {
- if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 })
- editor.focus()
- }
- }
- const handlePrompt = async (
- prompt: string,
- context: {
- beforeSelection: string
- selection: string
- afterSelection: string
- }
- ) => {
- try {
- setPromptState((prev) => ({
- ...prev,
- selection: context.selection,
- beforeSelection: context.beforeSelection,
- afterSelection: context.afterSelection,
- }))
- const headerData = await constructHeaders()
- const authorizationHeader = headerData.get('Authorization')
- await complete(prompt, {
- ...(authorizationHeader ? { headers: { Authorization: authorizationHeader } } : undefined),
- body: {
- ...aiMetadata,
- completionMetadata: {
- textBeforeCursor: context.beforeSelection,
- textAfterCursor: context.afterSelection,
- language,
- prompt,
- selection: context.selection,
- },
- },
- })
- } catch (error) {
- setPromptState((prev) => ({ ...prev, isOpen: false }))
- }
- }
- const defaultOptions: monacoEditor.IStandaloneEditorConstructionOptions = {
- tabSize: 2,
- fontSize: 13,
- readOnly,
- minimap: { enabled: false },
- wordWrap: 'on',
- lineNumbers: 'on',
- folding: false,
- padding: { top: 4 },
- lineNumbersMinChars: 3,
- ...options,
- }
- useEffect(() => {
- setCurrentValue(value || defaultValue)
- }, [value, defaultValue])
- useEffect(() => {
- if (initialPrompt) {
- setPromptInput(initialPrompt)
- setPromptState({
- isOpen: Boolean(initialPrompt),
- selection: '',
- beforeSelection: '',
- afterSelection: '',
- startLineNumber: 0,
- endLineNumber: 0,
- })
- }
- }, [initialPrompt])
- useEffect(() => {
- if (!isDiffMode) {
- setIsDiffEditorMounted(false)
- }
- }, [isDiffMode])
- useEffect(() => {
- const handleKeyboard = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- handleReset()
- } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && isDiffMode) {
- event.preventDefault()
- handleAcceptDiff()
- }
- }
- window.addEventListener('keydown', handleKeyboard)
- return () => window.removeEventListener('keydown', handleKeyboard)
- }, [isDiffMode, handleAcceptDiff, handleReset])
- return (
- <div className="flex-1 overflow-hidden flex flex-col h-full relative">
- {isDiffMode ? (
- <div className="w-full h-full">
- <DiffEditor
- language={language}
- original={diffValue.original}
- modified={diffValue.modified}
- onMount={(editor: monacoEditor.IStandaloneDiffEditor) => {
- diffEditorRef.current = editor
- setIsDiffEditorMounted(true)
- }}
- />
- {isDiffEditorMounted && (
- <ResizableAIWidget
- editor={diffEditorRef.current!}
- id="ask-ai-diff"
- value={promptInput}
- onChange={setPromptInput}
- onSubmit={(prompt: string) => {
- handlePrompt(prompt, {
- beforeSelection: promptState.beforeSelection,
- selection: promptState.selection || diffValue.modified,
- afterSelection: promptState.afterSelection,
- })
- }}
- onAccept={handleAcceptDiff}
- onReject={handleRejectDiff}
- onCancel={handleReset}
- isDiffVisible={true}
- isLoading={isCompletionLoading}
- startLineNumber={Math.max(0, promptState.startLineNumber)}
- endLineNumber={promptState.endLineNumber}
- />
- )}
- </div>
- ) : (
- <div className="w-full h-full relative">
- {/* [Joshen] Refactor: Use CodeEditor.tsx instead, reduce duplicate declaration of Editor */}
- <Editor
- theme="briven"
- language={language}
- value={currentValue}
- options={defaultOptions}
- onChange={(value: string | undefined) => {
- const newValue = value || ''
- setCurrentValue(newValue)
- onChange?.(newValue)
- }}
- onMount={handleEditorOnMount}
- className={className}
- />
- {promptState.isOpen && editorRef.current && (
- <ResizableAIWidget
- editor={editorRef.current}
- id="ask-ai"
- value={promptInput}
- onChange={setPromptInput}
- onSubmit={(prompt: string) => {
- handlePrompt(prompt, {
- beforeSelection: promptState.beforeSelection,
- selection: promptState.selection,
- afterSelection: promptState.afterSelection,
- })
- }}
- onCancel={handleReset}
- isDiffVisible={false}
- isLoading={isCompletionLoading}
- startLineNumber={Math.max(0, promptState.startLineNumber)}
- endLineNumber={promptState.endLineNumber}
- />
- )}
- <AnimatePresence>
- {!promptState.isOpen && !currentValue && aiEndpoint && (
- <motion.p
- initial={{ y: 5, opacity: 0 }}
- animate={{ y: 0, opacity: 1 }}
- exit={{ y: 5, opacity: 0 }}
- className="text-foreground-lighter absolute bottom-4 left-4 z-10 font-mono text-xs flex items-center gap-1"
- >
- Hit{' '}
- <KeyboardShortcut
- keys={['Meta', 'Shift', 'k']}
- variant="inline"
- className="text-xs text-foreground-lighter"
- />{' '}
- to edit with the Assistant
- </motion.p>
- )}
- </AnimatePresence>
- </div>
- )}
- </div>
- )
- }
|