| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- import Editor, { Monaco, OnMount } from '@monaco-editor/react'
- import { useDebounce } from '@uidotdev/usehooks'
- import { LOCAL_STORAGE_KEYS, useParams } from 'common'
- import { useRouter } from 'next/router'
- import { MutableRefObject, useEffect, useRef, useState } from 'react'
- import { cn } from 'ui'
- import { Admonition } from 'ui-patterns'
- import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
- import type { IStandaloneCodeEditor } from './SQLEditor.types'
- import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils'
- import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useProfile } from '@/lib/profile'
- import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
- import { useTabsStateSnapshot } from '@/state/tabs'
- export type MonacoEditorProps = {
- id: string
- snippetName: string
- className?: string
- editorRef: MutableRefObject<IStandaloneCodeEditor | null>
- monacoRef: MutableRefObject<Monaco | null>
- autoFocus?: boolean
- executeQuery: () => void
- executeExplainQuery: () => void
- prettifyQuery: () => void
- onHasSelection: (value: boolean) => void
- onMount?: (editor: IStandaloneCodeEditor) => void
- onPrompt?: (value: {
- selection: string
- beforeSelection: string
- afterSelection: string
- startLineNumber: number
- endLineNumber: number
- }) => void
- placeholder?: string
- }
- const MonacoEditor = ({
- id,
- snippetName,
- editorRef,
- monacoRef,
- autoFocus = true,
- placeholder = '',
- className,
- executeQuery,
- executeExplainQuery,
- prettifyQuery,
- onHasSelection,
- onPrompt,
- onMount,
- }: MonacoEditorProps) => {
- const router = useRouter()
- const { profile } = useProfile()
- const { ref, content } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const snapV2 = useSqlEditorV2StateSnapshot()
- const tabsSnap = useTabsStateSnapshot()
- const aiSnap = useAiAssistantStateSnapshot()
- const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot()
- const [intellisenseEnabled] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
- true
- )
- const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
- const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
- const setCommandMenuOpen = useSetCommandMenuOpen()
- // [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce
- const [value, setValue] = useState('')
- const debouncedValue = useDebounce(value, 1000)
- const snippet = snapV2.snippets[id]
- const disableEdit =
- snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id
- const executeQueryRef = useRef(executeQuery)
- executeQueryRef.current = executeQuery
- const executeExplainQueryRef = useRef(executeExplainQuery)
- executeExplainQueryRef.current = executeExplainQuery
- const prettifyQueryRef = useRef(prettifyQuery)
- prettifyQueryRef.current = prettifyQuery
- const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled)
- aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled
- const commandMenuHotkeyEnabledRef = useRef(isCommandMenuHotkeyEnabled)
- commandMenuHotkeyEnabledRef.current = isCommandMenuHotkeyEnabled
- const setCommandMenuOpenRef = useRef(setCommandMenuOpen)
- setCommandMenuOpenRef.current = setCommandMenuOpen
- const handleEditorOnMount: OnMount = async (editor, monaco) => {
- editorRef.current = editor
- monacoRef.current = monaco
- const model = editorRef.current.getModel()
- if (model !== null) {
- monacoRef.current.editor.setModelMarkers(model, 'owner', [])
- }
- // Blur the editor on Escape so users can hop out to the rest of the UI.
- // The precondition defers to Monaco's own Escape consumers (suggest widget,
- // find widget, parameter hints, snippet/rename mode, inline suggestions) and
- // to selection/multi-cursor cancellation, so inline features keep working.
- editor.addCommand(
- monaco.KeyCode.Escape,
- () => {
- ;(document.activeElement as HTMLElement | null)?.blur()
- },
- [
- 'editorTextFocus',
- '!editorHasSelection',
- '!editorHasMultipleSelections',
- '!suggestWidgetVisible',
- '!findWidgetVisible',
- '!parameterHintsVisible',
- '!renameInputVisible',
- '!inSnippetMode',
- '!accessibilityHelpWidgetVisible',
- '!inlineSuggestionVisible',
- ].join(' && ')
- )
- editor.addAction({
- id: 'run-query',
- label: 'Run Query',
- keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
- contextMenuGroupId: 'operation',
- contextMenuOrder: 0,
- run: () => {
- executeQueryRef.current()
- },
- })
- editor.addAction({
- id: 'run-explain-query',
- label: 'Run EXPLAIN ANALYZE',
- keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter],
- contextMenuGroupId: 'operation',
- contextMenuOrder: 1,
- run: () => {
- executeExplainQueryRef.current()
- },
- })
- editor.addAction({
- id: 'save-query',
- label: 'Save Query',
- keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyS],
- contextMenuGroupId: 'operation',
- contextMenuOrder: 0,
- run: () => {
- if (snippet) snapV2.addNeedsSaving(snippet.snippet.id)
- },
- })
- editor.addAction({
- id: 'prettify-query',
- label: 'Prettify SQL',
- keybindings: [monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF],
- contextMenuGroupId: 'operation',
- contextMenuOrder: 2,
- run: () => {
- prettifyQueryRef.current()
- },
- })
- editor.addAction({
- id: 'explain-code',
- label: 'Explain Code',
- contextMenuGroupId: 'operation',
- contextMenuOrder: 1,
- run: () => {
- const selectedValue = (editorRef?.current as any)
- .getModel()
- .getValueInRange((editorRef?.current as any)?.getSelection())
- openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- aiSnap.newChat({
- name: 'Explain code section',
- sqlSnippets: [selectedValue],
- initialInput: 'Can you explain this section to me in more detail?',
- })
- },
- })
- editor.addAction({
- id: 'toggle-ai-assistant',
- label: 'Toggle AI Assistant',
- keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI],
- run: () => {
- if (aiHotkeyEnabledRef.current) {
- toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- }
- },
- })
- if (onPrompt) {
- editor.addAction({
- id: 'generate-sql',
- label: 'Generate SQL',
- keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK],
- run: () => {
- const selectionParts = getEditorSelectionParts(editor)
- if (selectionParts) onPrompt(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)
- }
- })
- editor.onDidChangeCursorSelection(({ selection }) => {
- const noSelection =
- selection.startLineNumber === selection.endLineNumber &&
- selection.startColumn === selection.endColumn
- onHasSelection(!noSelection)
- })
- if (autoFocus) {
- if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 })
- editor.focus()
- }
- onMount?.(editor)
- }
- function handleEditorChange(value: string | undefined) {
- tabsSnap.makeActiveTabPermanent()
- if (id && value) {
- if (!snippet && ref && profile !== undefined && project !== undefined) {
- const snippet = createSqlSnippetSkeletonV2({
- idOverride: id,
- name: snippetName,
- sql: value,
- owner_id: profile?.id,
- project_id: project?.id,
- })
- snapV2.addSnippet({ projectRef: ref, snippet })
- router.push(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true })
- }
- setValue(value)
- }
- }
- useEffect(() => {
- if (debouncedValue.length > 0 && snippet) {
- const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet
- snapV2.setSql({ id, sql: value, shouldInvalidate })
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedValue])
- // if an SQL query is passed by the content parameter, set the editor value to its content. This
- // is usually used for sending the user to SQL editor from other pages with SQL.
- useEffect(() => {
- if (content && content.length > 0) handleEditorChange(content)
- }, [])
- return (
- <>
- {disableEdit && (
- <Admonition
- type="default"
- className="rounded-none border-0 border-b"
- title="Read-only snippet"
- description="This snippet has been shared to the project and is only editable by the owner who created this snippet. You may duplicate this snippet into a personal copy by right clicking on the snippet and selecting “Duplicate query”."
- />
- )}
- <Editor
- className={cn(className, 'monaco-editor')}
- theme={'briven'}
- onMount={handleEditorOnMount}
- onChange={handleEditorChange}
- defaultLanguage="pgsql"
- defaultValue={snippet?.snippet.content?.unchecked_sql}
- path={id}
- options={{
- tabSize: 2,
- fontSize: 13,
- placeholder,
- lineDecorationsWidth: 0,
- readOnly: disableEdit,
- minimap: { enabled: false },
- wordWrap: 'on',
- padding: { top: 4 },
- // [Joshen] Commenting the following out as it causes the autocomplete suggestion popover
- // to be positioned wrongly somehow. I'm not sure if this affects anything though, but leaving
- // comment just in case anyone might be wondering. Relevant issues:
- // - https://github.com/microsoft/monaco-editor/issues/2229
- // - https://github.com/microsoft/monaco-editor/issues/2503
- // fixedOverflowWidgets: true,
- suggest: {
- showMethods: intellisenseEnabled,
- showFunctions: intellisenseEnabled,
- showConstructors: intellisenseEnabled,
- showDeprecated: intellisenseEnabled,
- showFields: intellisenseEnabled,
- showVariables: intellisenseEnabled,
- showClasses: intellisenseEnabled,
- showStructs: intellisenseEnabled,
- showInterfaces: intellisenseEnabled,
- showModules: intellisenseEnabled,
- showProperties: intellisenseEnabled,
- showEvents: intellisenseEnabled,
- showOperators: intellisenseEnabled,
- showUnits: intellisenseEnabled,
- showValues: intellisenseEnabled,
- showConstants: intellisenseEnabled,
- showEnums: intellisenseEnabled,
- showEnumMembers: intellisenseEnabled,
- showKeywords: intellisenseEnabled,
- showWords: intellisenseEnabled,
- showColors: intellisenseEnabled,
- showFiles: intellisenseEnabled,
- showReferences: intellisenseEnabled,
- showFolders: intellisenseEnabled,
- showTypeParameters: intellisenseEnabled,
- showIssues: intellisenseEnabled,
- showUsers: intellisenseEnabled,
- showSnippets: intellisenseEnabled,
- },
- }}
- />
- </>
- )
- }
- export default MonacoEditor
|