import React, { useEffect, useRef } from 'react'; import MonacoEditor from 'react-monaco-editor'; import * as monaco from 'monaco-editor'; import { promLanguageDefinition } from 'monaco-promql'; import type * as monacoTypes from 'monaco-editor'; import { parser } from '@fc-components/lezer-metricsql'; import { v4 as uuidv4 } from 'uuid'; import { css } from '@emotion/css'; import { installAutoHeightLayout } from '../shared/autoHeightLayout'; import { hideMirroredInputAreaClassName } from '../shared/hideMirroredInputArea'; import { language, languageConfiguration } from './promql'; import { DataProvider } from './completion/DataProvider'; import { getCompletionProvider } from './completion/getCompletionProvider'; import type { DataProviderParams } from './types'; import { validateQuery } from './validation'; interface PromQLEditorProps { className?: string; size?: 'small' | 'middle' | 'large'; theme?: 'light' | 'dark'; value?: string; placeholder?: string; enableAutocomplete?: boolean; durationVariablesCompletion?: boolean; // When false, DataProvider will not send network requests. Default: true enableRequests?: boolean; readOnly?: boolean; disabled?: boolean; interpolateString?: (query: string) => string; onChange?: (value: string) => void; onEnter?: (value: string) => void; onBlur?: (value: string) => void; editorDidMount?: (editor: monacoTypes.editor.IStandaloneCodeEditor) => void; } const PROMQL_LANG_ID = promLanguageDefinition.id; const SIZE_MAP: Record< string, { className: string; top: number; bottom: number; } > = { small: { className: 'ant-input-sm', top: 1, bottom: 1, }, middle: { className: 'ant-input-md', top: 1, bottom: 1, }, large: { className: 'ant-input-lg', top: 3, bottom: 2, }, }; const themeMap: Record = { light: 'n9e-light', dark: 'n9e-dark', }; const containerDisabledClassName = css` .monaco-editor { user-select: none; pointer-events: none; } `; const containerReadOnlyClassName = css` .monaco-editor .cursors-layer > .cursor { opacity: 0 !important; /* 完全透明 */ } `; const getStyles = (placeholder?: string) => { return { placeholder: css({ '::after': { content: `'${placeholder}'`, opacity: 0.6, }, }), }; }; export default function PromQLEditor(props: PromQLEditorProps & DataProviderParams) { const id = uuidv4(); const { className, size = 'middle', theme = 'light', value, placeholder, interpolateString, enableAutocomplete = true, enableRequests = true, readOnly = false, disabled = false, onChange, onEnter, onBlur, editorDidMount, } = props; const autocompleteDisposeFun = useRef<(() => void) | null>(null); const containerRef = useRef(null); const dataProviderRef = useRef(null); const editorRef = useRef(null); const previousContentRef = useRef(''); const lastDeletionTriggerTimeRef = useRef(0); const editorFocusedRef = useRef(false); const styles = getStyles(placeholder); const handleEditorDidMount = (editor: monacoTypes.editor.IStandaloneCodeEditor) => { editorRef.current = editor; monaco.editor.defineTheme('n9e-light', { base: 'vs', inherit: true, rules: [], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); monaco.editor.defineTheme('n9e-dark', { base: 'vs-dark', inherit: true, rules: [], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); const isEditorFocused = editor.createContextKey('isEditorFocused' + id, false); // we setup on-blur editor.onDidBlurEditorWidget(() => { isEditorFocused.set(false); editorFocusedRef.current = false; onBlur?.(editor.getValue()); // reset the selection to the current position const position = editor.getPosition(); if (position) { const newSelection = new monaco.Selection(position.lineNumber, position.column, position.lineNumber, position.column); editor.setSelection(newSelection); } }); editor.onDidFocusEditorText(() => { isEditorFocused.set(true); editorFocusedRef.current = true; }); if (containerRef.current) { installAutoHeightLayout(editor, containerRef.current); } // Fixes Monaco capturing the search key binding and displaying a useless search box within the Editor. monaco.editor.addKeybindingRule({ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, command: null, }); // 设置 Shift + Enter 为在光标位置换行 editor.addCommand( monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { // 在光标位置插入换行符 const position = editor.getPosition(); if (position) { editor.executeEdits('shift-enter', [ { range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column), text: '\n', }, ]); // 将光标移动到新行 editor.setPosition({ lineNumber: position.lineNumber + 1, column: 1, }); } }, 'isEditorFocused' + id, ); // 完全阻止 Enter 键的默认行为(包括换行) monaco.editor.addKeybindingRule({ keybinding: monaco.KeyCode.Enter, command: '-', when: '!suggestWidgetVisible', }); // handle: enter - 只有在没有建议窗口时才执行自定义行为 editor.addCommand( monaco.KeyCode.Enter, () => { onEnter?.(editor.getValue()); }, '!suggestWidgetVisible && isEditorFocused' + id, ); // Initialize previous content tracking previousContentRef.current = editor.getValue(); lastDeletionTriggerTimeRef.current = 0; const DELETION_TRIGGER_DEBOUNCE_MS = 1000; editor.onDidChangeModelContent(() => { const model = editor.getModel(); if (model) { const query = model.getValue(); // 编辑器无焦点时,视为外部程序化回填,跳过删除检测的 triggerSuggest const isExternalChange = !editorFocusedRef.current; const errors = validateQuery(query, interpolateString ? interpolateString(query) : query, model.getLinesContent(), parser) || []; const markers = errors.map(({ error, ...boundary }) => ({ message: `${error ? `Error parsing "${error}"` : 'Parse error'}. The query appears to be incorrect and could fail to be executed.`, severity: monaco.MarkerSeverity.Error, ...boundary, })); monaco.editor.setModelMarkers(model, 'owner', markers); // Detect deletion and trigger completion (仅用户编辑时触发,外部回填跳过) const previousContent = previousContentRef.current; const isDeletion = query.length < previousContent.length; if (isDeletion && enableAutocomplete && !isExternalChange) { const now = Date.now(); if (now - lastDeletionTriggerTimeRef.current >= DELETION_TRIGGER_DEBOUNCE_MS) { lastDeletionTriggerTimeRef.current = now; // Trigger completion after deletion setTimeout(() => { editor.trigger('keyboard', 'editor.action.triggerSuggest', {}); }, 0); } } previousContentRef.current = query; } }); editorDidMount?.(editor); }; useEffect(() => { // 注册 PromQL 语言 const { aliases, extensions, mimetypes } = promLanguageDefinition; monaco.languages.register({ id: PROMQL_LANG_ID, aliases, extensions, mimetypes, }); // 设置语法高亮 monaco.languages.setMonarchTokensProvider(PROMQL_LANG_ID, language as any); // 设置语言配置 monaco.languages.setLanguageConfiguration(PROMQL_LANG_ID, languageConfiguration as any); return () => { autocompleteDisposeFun.current?.(); dataProviderRef.current = null; }; }, []); useEffect(() => { const editor = editorRef.current; if (!editor) return; // clean up previous autocomplete provider if (autocompleteDisposeFun.current) { autocompleteDisposeFun.current(); autocompleteDisposeFun.current = null; } dataProviderRef.current = null; // If autocomplete is enabled, set up a new autocomplete provider if (enableAutocomplete) { const dataProvider = new DataProvider({ url: props.url, lookbackInterval: props.lookbackInterval, variablesNames: props.variablesNames, durationVariablesCompletion: props.durationVariablesCompletion, request: props.request, httpMethod: props.httpMethod, apiPrefix: props.apiPrefix, httpErrorHandler: props.httpErrorHandler, enableRequests: enableRequests, }); dataProviderRef.current = dataProvider; dataProvider.start(); const completionProvider = getCompletionProvider(monaco, dataProvider); const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = { ...completionProvider, provideCompletionItems: (model, position, context, token) => { if (editor.getModel()?.id !== model.id) { return { suggestions: [] }; } return completionProvider.provideCompletionItems(model, position, context, token); }, }; const disposable = monaco.languages.registerCompletionItemProvider(PROMQL_LANG_ID, filteringCompletionProvider); autocompleteDisposeFun.current = () => disposable.dispose(); } // clean up previous placeholder decorations const model = editor.getModel(); if (model) { model.deltaDecorations( model.getAllDecorations().map((d) => d.id), [], ); } if (placeholder) { const placeholderDecorators = [ { range: new monaco.Range(1, 1, 1, 1), options: { className: styles.placeholder, isWholeLine: true, }, }, ]; let decorators: string[] = []; const checkDecorators: () => void = () => { const model = editor.getModel(); if (!model) { return; } const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : []; decorators = model.deltaDecorations(decorators, newDecorators); }; checkDecorators(); editor.onDidChangeModelContent(checkDecorators); } }, [ enableAutocomplete, props.url, props.lookbackInterval, JSON.stringify(props.variablesNames), props.durationVariablesCompletion, props.httpMethod, props.apiPrefix, placeholder, enableRequests, ]); return (
); }