Files
supabase/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
Charis 0433eeb5f5 feat(studio): mark sql provenance for safety (#45336)
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 -->
2026-05-04 13:08:06 -04:00

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