mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Mark provenance of SQL via the branded types SafeSqlFragment and UntrustedSqlFragment. Only SafeSqlFragment should be executed; UntrustedSqlFragments require some kind of implicit user approval (show on screen + user has to click something) before they are promoted to SafeSqlFragment. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Editor and RLS tester show loading states for inferred/generated SQL and include a dedicated user SQL editor for safer edits. * **Refactor** * Platform-wide SQL handling tightened: snippets and AI-generated SQL are treated as untrusted/display-only until promoted, improving safety and consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
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={'supabase'}
|
|
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
|