import React, { useEffect, useRef } from 'react'; import MonacoEditor from 'react-monaco-editor'; import * as monaco from 'monaco-editor'; import type * as monacoTypes from 'monaco-editor'; import { v4 as uuidv4 } from 'uuid'; import { css } from '@emotion/css'; import { installAutoHeightLayout } from '../shared/autoHeightLayout'; import { languageConfiguration } from './logql'; import { buildMonarchLanguage, languageIdFor } from './monarch/buildLanguage'; import { getLogQLCompletionProvider } from './completion/getCompletionProvider'; import { getVendorConfig } from './vendors'; import type { LogQLEditorProps } from './types'; import { saveHistory } from './history'; export type { LogQLEditorProps, LogQLFieldCompletionContext, LogQLLanguage, LogQLVendor, LogQLSection, VendorConfig } from './types'; const SIZE_MAP: Record< string, { className: string; minHeight: number; } > = { small: { className: 'ant-input-sm', minHeight: 24 }, middle: { className: 'ant-input-md', minHeight: 32 }, large: { className: 'ant-input-lg', minHeight: 40 }, }; const themeMap: Record = { light: 'logql-light', dark: 'logql-dark', }; const containerDisabledClassName = css` .monaco-editor { user-select: none; pointer-events: none; } `; const containerReadOnlyClassName = css` .monaco-editor .cursors-layer > .cursor { opacity: 0 !important; } `; const completionLayoutClassName = css` .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main > .left > .monaco-icon-label { flex-shrink: 1; min-width: 0; } .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main > .right { flex: 0 0 auto; max-width: none; } `; const registeredLanguages = new Set(); function ensureLanguage(vendorId: LogQLEditorProps['vendor']) { const id = languageIdFor(vendorId); if (registeredLanguages.has(id)) return id; if (!monaco.languages.getLanguages().some((lang) => lang.id === id)) { monaco.languages.register({ id }); monaco.languages.setLanguageConfiguration(id, languageConfiguration); monaco.languages.setMonarchTokensProvider(id, buildMonarchLanguage(getVendorConfig(vendorId))); } registeredLanguages.add(id); return id; } function ensureThemes() { monaco.editor.defineTheme('logql-light', { base: 'vs', inherit: true, rules: [ { token: 'type.identifier', foreground: '0070c1' }, { token: 'variable', foreground: 'b8860b', fontStyle: 'bold' }, { token: 'operator.pipe', foreground: 'b8860b', fontStyle: 'bold' }, { token: 'keyword.function', foreground: '795e26' }, ], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); monaco.editor.defineTheme('logql-dark', { base: 'vs-dark', inherit: true, rules: [ { token: 'type.identifier', foreground: '9cdcfe' }, { token: 'variable', foreground: 'ffd700', fontStyle: 'bold' }, { token: 'operator.pipe', foreground: 'ffd700', fontStyle: 'bold' }, { token: 'keyword.function', foreground: 'dcdcaa' }, ], colors: { 'editor.background': '#00000000', focusBorder: '#00000000', }, }); } /* ---------- Global keybinding rules registered once ---------- */ let globalKeybindingDisposables: monaco.IDisposable[] | null = null; function ensureGlobalKeybindings() { if (globalKeybindingDisposables) return; globalKeybindingDisposables = [ monaco.editor.addKeybindingRule({ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, command: null, }), monaco.editor.addKeybindingRule({ keybinding: monaco.KeyCode.Enter, command: '-', when: '!suggestWidgetVisible', }), ]; } function disposeGlobalKeybindings() { if (!globalKeybindingDisposables) return; globalKeybindingDisposables.forEach((d) => d.dispose()); globalKeybindingDisposables = null; } const LogQLEditor: React.FC = (props) => { const id = uuidv4(); const { vendor, className, maxHeight, fontSize, size = 'middle', theme = 'light', value = '', placeholder, enableAutocomplete = true, readOnly = false, disabled = false, variables, language = 'zh_CN', historyKey, onChange, onEnter, onBlur, onFocus, editorDidMount, fetchFieldNames, fetchFieldValues, } = props; const containerRef = useRef(null); const editorRef = useRef(null); const disposablesRef = useRef([]); const variablesRef = useRef(variables ?? []); const languageRef = useRef(language); const historyKeyRef = useRef(historyKey); const canShowHistoryRef = useRef(value === ''); const fetchFieldNamesRef = useRef(fetchFieldNames); const fetchFieldValuesRef = useRef(fetchFieldValues); useEffect(() => { variablesRef.current = variables ?? []; }, [variables]); useEffect(() => { languageRef.current = language; }, [language]); useEffect(() => { historyKeyRef.current = historyKey; }, [historyKey]); useEffect(() => { fetchFieldNamesRef.current = fetchFieldNames; }, [fetchFieldNames]); useEffect(() => { fetchFieldValuesRef.current = fetchFieldValues; }, [fetchFieldValues]); const languageId = ensureLanguage(vendor); useEffect(() => { if (!enableAutocomplete) return; const disposable = monaco.languages.registerCompletionItemProvider( languageId, getLogQLCompletionProvider({ vendor: getVendorConfig(vendor), getVariables: () => variablesRef.current, getLanguage: () => languageRef.current, getHistoryKey: () => historyKeyRef.current, getCanShowHistory: () => canShowHistoryRef.current, getFetchFieldNames: () => fetchFieldNamesRef.current, getFetchFieldValues: () => fetchFieldValuesRef.current, editorRef, }), ); disposablesRef.current.push(disposable); return () => { disposable.dispose(); disposablesRef.current = disposablesRef.current.filter((d) => d !== disposable); }; }, [enableAutocomplete, vendor, languageId]); useEffect(() => { ensureGlobalKeybindings(); return () => { disposeGlobalKeybindings(); disposablesRef.current.forEach((d) => d.dispose()); disposablesRef.current = []; }; }, []); const handleEditorMount = (editor: monacoTypes.editor.IStandaloneCodeEditor) => { editorRef.current = editor; ensureThemes(); const isEditorFocused = editor.createContextKey('isEditorFocused' + id, false); editor.onDidBlurEditorWidget(() => { isEditorFocused.set(false); saveHistory(historyKeyRef.current, editor.getValue()); onBlur?.(editor.getValue()); 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); canShowHistoryRef.current = editor.getValue() === ''; onFocus?.(editor.getValue()); }); if (containerRef.current) { installAutoHeightLayout(editor, containerRef.current, { heightStyleProperty: 'minHeight' }); } 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, ); editor.addCommand( monaco.KeyCode.Enter, () => { saveHistory(historyKeyRef.current, editor.getValue()); onEnter?.(editor.getValue()); }, '!suggestWidgetVisible && isEditorFocused' + id, ); // 兜底触发变量补全:监听 model content 变化,如果新增的是 `$`,主动唤起 // suggest widget。triggerCharacters 在已有 token 中间输入时不一定触发。 const model = editor.getModel(); if (model) { const changeDisposable = model.onDidChangeContent((e) => { if (editor.getValue() === '') { canShowHistoryRef.current = true; } const insertedDollar = e.changes.some((c) => c.text === '$'); if (insertedDollar) { setTimeout(() => editor.trigger('logql', 'editor.action.triggerSuggest', {}), 0); } }); disposablesRef.current.push(changeDisposable); } editorDidMount?.(editor); }; const handleChange = (newValue: string) => { onChange?.(newValue); }; const themeValue = themeMap[theme]; return (
); }; export default LogQLEditor; // Re-export the locator helper for advanced consumers that may want to drive // their own UI (e.g. custom suggestion popovers) from the AST. export { parse, locate } from './parser'; export { getVendorConfig } from './vendors';