index.tsx 15 KB

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