mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## What
PR 7 of the SQL editor state re-layering stack. Extracts the snippet
editing lifecycle out of `MonacoEditor` into a co-located
`useSnippetEditor` hook, and consolidates the edit debounce.
`useSnippetEditor` owns:
- creating the snippet in the store on first edit and routing to its URL
(replace vs push for a `?content=` deep link)
- writing changes back to the store via `setSql` (with
`wasNeverPersisted` → `shouldInvalidate`)
- seeding the editor from the `content` param
- the read-only determination (`canEditSnippet`)
`MonacoEditor` now consumes `{ snippet, disableEdit, handleEditorChange
}` and keeps only the editor shell + Monaco action wiring. It sheds the
`router`/`profile`/`project`/`params`/store/tabs hooks.
`handleEditorChange` was also flattened with an early return.
## Debounce consolidation
Previously there were **two 1s debounces in series**: `useSnippetEditor`
debounced editor changes before writing to the store, and the save
mechanism (`createSaveMechanism`) already debounces persistence. That
added latency (up to ~2s to save) and split the "when to persist" timing
policy across two layers — at odds with PR 5's design where the
scheduler/mechanism owns *when* and dirty state is meant to be
immediate.
This PR removes the editor-side debounce: edits write to the store
synchronously on every change, and the save mechanism's 1s debounce is
the sole throttle. Net effects:
- the store — and the snippet's dirty status — reflects the latest edit
immediately (correct for the future manual-save mode's Save button / nav
guard)
- save fires ~1s after the *last* keystroke instead of up to ~2s
- only the active snippet's own reactive consumers (a lightweight
sidebar item) re-render per keystroke; Monaco is uncontrolled
(`defaultValue`) so it is unaffected
Note: the double-debounce was legacy (the pre-refactor god store had the
same `useDebounce(value, 1000)` in MonacoEditor plus a debounced
module-load subscribe).
## Notes
- Behavior-preserving in outcome — autosave still lands ~1s after typing
stops, just with lower latency and immediate store consistency.
## Validation
- `pnpm --filter studio typecheck` ✅
- `pnpm exec vitest --run state/sql-editor/` ✅ (110 passed)
- lint ✅ (0 errors; no ratcheted-rule regressions)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* SQL editor changes now apply immediately, with unsaved status
reflected as soon as you edit.
* The editor now keeps the latest snippet details available for saving,
improving reliability when using “Save Query.”
* **Bug Fixes**
* Improved handling for creating and opening snippets from shared links
or prefilled content.
* Fixed status updates so saved snippets correctly switch to unsaved
after edits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
274 lines
9.1 KiB
TypeScript
274 lines
9.1 KiB
TypeScript
import { Monaco, OnMount } from '@monaco-editor/react'
|
|
import { LOCAL_STORAGE_KEYS } from 'common'
|
|
import { noop } from 'lodash'
|
|
import { RefObject, useRef } from 'react'
|
|
import { Admonition } from 'ui-patterns/admonition'
|
|
|
|
import type { IStandaloneCodeEditor } from './SQLEditor.types'
|
|
import { useSnippetEditor } from './useSnippetEditor'
|
|
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
|
import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils'
|
|
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
|
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
|
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 { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator'
|
|
|
|
export type MonacoEditorProps = {
|
|
id: string
|
|
snippetName: string
|
|
className?: string
|
|
editorRef: RefObject<IStandaloneCodeEditor | null>
|
|
monacoRef: RefObject<Monaco | null>
|
|
autoFocus?: boolean
|
|
executeQuery: () => void
|
|
executeExplainQuery: () => void
|
|
showExplainAction?: boolean
|
|
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
|
|
}
|
|
|
|
export const MonacoEditor = ({
|
|
id,
|
|
snippetName,
|
|
editorRef,
|
|
monacoRef,
|
|
autoFocus = true,
|
|
placeholder = '',
|
|
className,
|
|
executeQuery,
|
|
executeExplainQuery,
|
|
showExplainAction = true,
|
|
prettifyQuery,
|
|
onHasSelection,
|
|
onPrompt,
|
|
onMount,
|
|
}: MonacoEditorProps) => {
|
|
const aiSnap = useAiAssistantStateSnapshot()
|
|
const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot()
|
|
|
|
const [intellisenseEnabled] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
|
|
true
|
|
)
|
|
|
|
const { snippet, disableEdit, handleEditorChange } = useSnippetEditor({ id, snippetName })
|
|
|
|
// The Monaco save action is registered once on mount, but `snippet` starts
|
|
// undefined for a new/deep-linked snippet and is only created on first edit.
|
|
// Read it through a ref so Cmd/Ctrl+S sees the latest value, not the stale
|
|
// mount-time closure.
|
|
const snippetRef = useRef(snippet)
|
|
snippetRef.current = snippet
|
|
|
|
const executeExplainQueryRef = useRef(executeExplainQuery)
|
|
executeExplainQueryRef.current = executeExplainQuery
|
|
|
|
const prettifyQueryRef = useRef(prettifyQuery)
|
|
prettifyQueryRef.current = prettifyQuery
|
|
|
|
const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
|
|
const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled)
|
|
aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled
|
|
|
|
const { requestSave } = useSqlEditorSaveCoordinator()
|
|
const requestSaveRef = useRef(requestSave)
|
|
requestSaveRef.current = requestSave
|
|
|
|
const handleEditorOnMount: OnMount = (editor, monaco) => {
|
|
const model = editor.getModel()
|
|
if (model !== null) {
|
|
monaco.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(' && ')
|
|
)
|
|
|
|
if (showExplainAction) {
|
|
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: () => {
|
|
const currentSnippet = snippetRef.current
|
|
if (currentSnippet) requestSaveRef.current(currentSnippet.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 selection = editorRef?.current?.getSelection()
|
|
if (!selection) return
|
|
|
|
const selectedValue = editorRef?.current?.getModel()?.getValueInRange(selection)
|
|
|
|
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)
|
|
},
|
|
})
|
|
}
|
|
|
|
editor.onDidChangeCursorSelection(({ selection }) => {
|
|
const noSelection =
|
|
selection.startLineNumber === selection.endLineNumber &&
|
|
selection.startColumn === selection.endColumn
|
|
onHasSelection(!noSelection)
|
|
})
|
|
|
|
onMount?.(editor)
|
|
}
|
|
|
|
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”."
|
|
/>
|
|
)}
|
|
<CodeEditor
|
|
id={id}
|
|
language="pgsql"
|
|
className={className}
|
|
autofocus={autoFocus}
|
|
isReadOnly={disableEdit}
|
|
defaultValue={snippet?.snippet.content?.unchecked_sql}
|
|
editorRef={editorRef}
|
|
monacoRef={monacoRef}
|
|
actions={{
|
|
runQuery: { enabled: true, callback: executeQuery },
|
|
formatDocument: { enabled: false, callback: noop },
|
|
placeholderFill: { enabled: false },
|
|
}}
|
|
options={{
|
|
placeholder,
|
|
lineDecorationsWidth: 0,
|
|
fixedOverflowWidgets: false,
|
|
lineNumbersMinChars: 5,
|
|
scrollBeyondLastLine: 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,
|
|
},
|
|
}}
|
|
onInputChange={handleEditorChange}
|
|
onMount={handleEditorOnMount}
|
|
/>
|
|
</>
|
|
)
|
|
}
|